function initXOpatUI() {
/**
* Window Dialogs: System Dialogs and Window Manager
* @namespace Dialogs
*/
window.Dialogs = {
MSG_INFO: Toast.MSG_INFO,
MSG_WARN: Toast.MSG_WARN,
MSG_ERR: Toast.MSG_ERROR,
MSG_SUCCESS: Toast.MSG_SUCCESS,
_scheduler: null,
/**
* Show notification
* @param {string} text HTML allowed, but be cautious about security. Use actions in props for activity.
* @param {number} delayMS >= 1000 autohides, <1000 sticks until closed
* @param importance one of Dialogs.MSG_*
* @param {object} props {queued?, onShow?, onHide?, buttons?, actions?}
*/
show(text, delayMS = 5000, importance = this.MSG_INFO, props = {}) {
this._scheduler.show(text, delayMS, importance, props);
},
/**
* Hide current notification
* @param withCallback
*/
hide(withCallback = true) {
this._scheduler.hide(withCallback);
},
/**
* Await dialogs are hidden and no messages are shown
* @return {Promise}
*/
async awaitHidden() {
await this._scheduler.awaitHidden();
},
/**
* Place the notification toast at the top or the bottom of the viewport.
* Persisted on the session via `APPLICATION_CONTEXT.setOption("notificationsPosition", ...)`
* so the choice survives serialization.
* @param {"top"|"bottom"} position
*/
setPosition(position) {
const pos = position === "top" ? "top" : "bottom";
this._view?.setPosition(pos);
APPLICATION_CONTEXT.setOption("notificationsPosition", pos);
},
init() {
if (this._scheduler) return;
const view = new UI.Toast();
const initialPosition = APPLICATION_CONTEXT.getOption("notificationsPosition", "bottom");
view.setPosition(initialPosition);
this._view = view;
this._scheduler = new UI.Toast.Scheduler(view);
(document.body || document.documentElement).appendChild(view.create());
view.setHoverHandlers?.(
() => this._scheduler.pause(),
() => this._scheduler.resume()
);
window.addEventListener("unload", () => UI.FloatingWindow.closeAllExternal());
},
/**
* Show custom/dialog window
* todo consider renaming
* @param parentId the ID of the plugin or module that attached this dialog
* @param header content to put in the header
* @param content content
* @param footer content to put to the footer
* @param params
* @param params.width custom width, can be a CSS value (string) or a number (pixels), by default it
* @param params.isBlocking whether the dialog should block the user from interacting with the page, default true
* @param params.allowClose whether to show 'close' button, default true
* @param params.allowResize whether to allow user to change the window size, default false
*/
showCustom: function(parentId, header, content, footer, params = {}) {
let modal = new UI.Modal({
header: header,
body: content,
footer: footer,
width: params.width,
isBlocking: params.isBlocking ?? true,
allowClose: params.allowClose ?? true,
allowResize: params.allowResize ?? false,
});
const node = modal.create();
if (parentId) {
node.classList.add(parentId + "-plugin-root");
} else {
console.warn("Dialogs.showCustom() called without parent ID.");
}
document.body.appendChild(node);
modal.open();
return modal;
},
}; // end of namespace Dialogs
Dialogs.init();
/**
* @typedef {{
* icon: string | undefined,
* iconCss: string | undefined,
* containerCss: string | undefined,
* title: string,
* action: function,
* selected: boolean | undefined
* }} DropDownItem
*/
/**
* @namespace DropDown
* todo either use lib or ensure window constrrains do not affect it (too low, too right)
*/
window.DropDown = /**@lends DropDown*/ {
_calls: [],
/**
* @private
*/
init: function() {
document.addEventListener("click", this._toggle.bind(this, undefined, undefined));
$("body").append(`<ul id="drop-down-menu" oncontextmenu="return false;" style="display:none;width: auto; max-width: 300px; z-index: 999999999; position: fixed;" class="menu menu-sm bg-base-100 rounded-box shadow"></ul>`);
this._body = $("#drop-down-menu");
},
/**
* Open dialog from fired user input event
* @param {Event} mouseEvent
* @param {function|Array<DropDownItem>} optionsGetter
*/
open: function(mouseEvent, optionsGetter) {
this._toggle(mouseEvent, optionsGetter);
mouseEvent.preventDefault();
},
/**
* @returns {boolean} true if opened
*/
isOpened: function () {
return this._calls.length > 0;
},
/**
*
* @param context a string or html node element, where to bind the click event
* @param optionsGetter callback that generates array of config options
* config object:
* config.title {string} title, required
* config.action {function} callback, argument given is 'selected' current value from config.icon
* - if undefined, the menu item is treated as separator - i.e. use '' title and undefined action for hr separator
* - you can also pass custom HTML and override the default styles and content, handler system etc...
* config.selected {boolean} whether to mark the option as selected, optional
* config.icon {string} custom option icon name, optional
* config.containerCss {string} css for the container, optional
* config.iconCss {string} css for icon
*/
bind: function(context, optionsGetter) {
if (typeof context === "string") {
context = document.getElementById(context);
}
if (!context?.nodeType) {
console.error("Registered dropdown for non-existing or invalid element", context);
return;
}
const _this = this;
context.addEventListener("contextmenu", (e) => {
_this._toggle(e, optionsGetter);
e.preventDefault();
});
},
hide: function() {
this._toggle(undefined);
},
//TODO: allow toggle to respect the viewport, e.g. switch vertical/horizontal or switch position
// if too close to edges
_toggle: function(mouseEvent, optionsGetter) {
const opened = this.isOpened();
if (mouseEvent === undefined) {
if (opened) {
this._calls = [];
this._body.html("");
this._body.css({
display: "none",
top: 99999,
left: 99999,
});
}
} else {
if (opened) {
this._calls = [];
this._body.html("");
}
((Array.isArray(optionsGetter) && optionsGetter) || optionsGetter()).forEach(this._with.bind(this));
let top = mouseEvent.pageY + 5;
let left = mouseEvent.pageX - 15;
if ((top + this._body.height()) > window.innerHeight) {
top = mouseEvent.pageY - this._body.height() - 5;
}
if ((left + this._body.width()) > window.innerWidth) {
left = mouseEvent.pageX - this._body.width() + 15;
}
this._body.css({
display: "block",
top: top,
left: left
});
}
},
_with(opts, i) {
const clbck = opts.action;
if (clbck) {
opts.selected = opts.selected || false;
this._calls.push(() => {
clbck(opts.selected);
window.DropDown._toggle(undefined, undefined);
});
const icon = opts.icon ? `<span class="fa-auto ${opts.icon} pl-0"
style="width: 20px;font-size: 17px;${opts.iconCss || ''}" onclick=""></span>`
: "<span class='d-inline-block' style='width: 20px'></span>";
const selected = opts.selected ? "style=\"background: var(--color-state-focus-border);\"" : "";
this._body.append(`<li ${selected}><a class="pl-1 dropdown-item pointer ${opts.containerCss || ''}"
onclick="window.DropDown._calls[${i}]();">${icon}${opts.title}</a></li>`);
} else {
this._calls.push(null);
this._body.append(`<li class="px-2" style="font-size: 10px;
border-bottom: 1px solid var(--color-border-primary);">${opts.title}</li>`);
}
}
};
DropDown.init();
let pluginsToolsBuilder, tissueMenuBuilder;
/**
* Definition of UI Namespaces driving menus and UI-ready utilities.
* @namespace USER_INTERFACE
*/
window.USER_INTERFACE = /**@lends USER_INTERFACE */ {
/**
* Run highlight animation on element
* @param id id of the element in DOM
* @param timeout highlight timeout in ms, default 2000
* @param animated default true
*/
highlightElementId(id, timeout = 2000, animated = true) {
let cls = animated ? "ui-highlight-animated" : "ui-highlight";
$(`#${id}`).addClass(cls);
setTimeout(() => $(`#${id}`).removeClass(cls), timeout);
},
/**
* Highlight element in DOM ensuring given menus are open it is
* contained in
* @param menuName menu type it is contained in, the name of the menu as in USER_INTERFACE
* @param {(string|string[])} menuId id of the menu to focus, applicable for menus that can switch between
* different contents
* @param id element ID to highlight
* @param timeout highlight timeout in ms, default 2000
* @param animated default true
*/
highlight(menuName, menuId, id, timeout = 2000, animated = true) {
this.focusMenu(menuName, menuId);
this.highlightElementId(id, timeout, animated);
},
/**
* Workspace (canvas) margins
* @private
* @namespace USER_INTERFACE.Margins
*/
Margins: {
top: 0,
bottom: 0,
left: 0,
right: 0
},
/**
* Dialog System
* @see Dialogs
* @memberOf USER_INTERFACE
*/
Dialogs: Dialogs,
/**
* DropDown Handler
* @see DropDown
* @memberOf USER_INTERFACE
*/
DropDown: DropDown,
/**
* Full screen Errors for critical failures.
* @namespace USER_INTERFACE.Errors
*/
Errors: {
active: false,
/**
* Show viewport-covering error
* @param title
* @param description
* @param withHiddenMenu
*/
show: function(title, description, withHiddenMenu = false) {
USER_INTERFACE.Tutorials._hideImpl(); //preventive
$("#system-message-title").html(title);
$("#system-message-details").html(description);
$("#system-message").removeClass("hidden");
$("body").addClass("disabled");
USER_INTERFACE.Tools.close();
this.active = true;
},
/**
* Hide system-wide error.
*/
hide: function() {
$("#system-message").addClass("hidden");
$("body").removeClass("disabled");
USER_INTERFACE.Tools.open();
this.active = false;
}
},
Tooltip: UI.Services.GlobalTooltip, //alias
MobileBottomBar: UI.Services.MobileBottomBar, //alias
//setup component in config.json -> can be added in URL, important setting such as removed cookies, theme etc -> can be set from outside
FullscreenMenu: {
get context() {
return UI.Services.FullscreenMenus.context;
},
get menu() {
return UI.Services.FullscreenMenus.menu;
},
init: function () {
const ctx = $("#fullscreen-menu")[0] || document.getElementById("fullscreen-menu") || document.body;
UI.Services.FullscreenMenus.init(ctx);
return UI.Services.FullscreenMenus.menu;
},
focus(id) {
return UI.Services.FullscreenMenus.focus(id);
},
open(id = undefined) {
return UI.Services.FullscreenMenus.open(id);
},
close() {
return UI.Services.FullscreenMenus.close();
},
setOrientation(orientation) {
return UI.Services.FullscreenMenus.setOrientation(orientation);
},
getSettingsBody: function () {
return UI.Services.FullscreenMenus.getSettingsBody;
},
createCheckbox: function (id, text, onchangeFunction, checked) {
return UI.Services.FullscreenMenus.createCheckbox(id, text, onchangeFunction, checked);
},
getLogo(positionBottom, positionRight) {
return UI.Services.FullscreenMenus.getLogo(positionBottom, positionRight);
},
getPluginsBody: function () {
return UI.Services.FullscreenMenus.getPluginsBody;
},
layout(...sections) {
return UI.Services.FullscreenMenus.layout(...sections);
},
card(title, ...children) {
return UI.Services.FullscreenMenus.card(title, ...children);
}
},
AppBar: UI.Services.AppBar,
/**
* Tools menu by default invisible (top)
* @namespace USER_INTERFACE.Tools
*/
Tools: {
/**
* Add menu to the Tools
* @param {string} ownerPluginId
* @param {string} toolsMenuId unique menu id
* @param {string} title
* @param {UIElement|UIElement[]} html
* @param {string} [icon=fa-wrench]
* @param {boolean} forceHorizontal
*/
setMenu(ownerPluginId, toolsMenuId, title, html, icon = "fa-wrench", forceHorizontal = false) {
if (!Array.isArray(html)) {
html = [html];
}
const menu = new UI.Toolbar(
{
id: `toolbar-${ownerPluginId}`,
horizontalOnly: forceHorizontal,
pluginRootClass: `plugin-${ownerPluginId}-root`,
embeddedTitle: title,
embeddedIcon: icon,
},
{
id: ownerPluginId+"-"+toolsMenuId+"-tools-panel",
icon: icon,
title: title,
body: html,
}
);
const container = window.LAYOUT?._toolbarFloatingEl || document.getElementById('toolbars-container');
if (container) {
menu.attachTo(container);
}
window.LAYOUT?.registerToolbar?.(menu);
menu.onLayoutChange({width: window.innerWidth});
// if (!APPLICATION_CONTEXT.getOption(`toolBar`, true)){
// document.querySelectorAll('div[id^="toolbar-"]').forEach((el) => el.classList.add("hidden"));
// }
//
// // snapping to left side if set in cookies
// if (APPLICATION_CONTEXT.AppCache.get(`toolbar-${ownerPluginId}-PositionLeft`) == 0){
// document.getElementById(`toolbar-${ownerPluginId}`).style["max-width"] = "100px";
// }
},
/**
* Show desired toolBar menu. Also opens the toolbar if closed.
* @param {(string|undefined)} toolsId menu id to open at
*/
open(toolsId = undefined) {
if (pluginsToolsBuilder) {
USER_INTERFACE.Margins.bottom = pluginsToolsBuilder.height;
pluginsToolsBuilder.show(toolsId);
}
},
/**
* Notify menu. The menu tab will receive a counter that notifies the user something has happened.
* @param {string} menuId menu id to open at
* @param {string} symbol a html symbol (that can be set as data- attribute) to show, shows increasing
* counter if undefined (e.g. 3 if called 3 times)
*/
notify(menuId, symbol = undefined) {
if (pluginsToolsBuilder) pluginsToolsBuilder.setNotify(menuId, symbol);
},
/**
* Close the menu, so that it is not visible at all.
*/
close() {
USER_INTERFACE.Margins.bottom = 0;
if (pluginsToolsBuilder) pluginsToolsBuilder.hide();
},
changeTheme(theme = undefined) {
// `UTILITIES.updateTheme(null)` is the init/refresh entry-point
// from `app.ts` and `viewer-open-pipeline.ts`; treat null and
// undefined the same (fall back to the session-config value).
if (theme === undefined || theme === null){
theme = APPLICATION_CONTEXT.getOption("theme", "auto");
}
// Supported values: "dark" | "light" | "auto" (auto follows the
// OS preference). Unknown values resolve to "light".
const useDark = theme === "dark" ||
(theme === "auto" && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.body.setAttribute("data-theme", useDark ? "xOpat-dark" : "xOpat-light");
// Persist the *intent* (auto/dark/light), not the resolved
// variant — otherwise calling this on init would silently
// collapse "auto" into the current OS preference and the user
// would lose their auto setting after a single refresh.
const persisted = theme === "auto" ? "auto" : (useDark ? "dark" : "light");
APPLICATION_CONTEXT.setOption("theme", persisted);
},
},
/**
* UI Fullscreen Loading
*/
Loading: {
_visible: $("#fullscreen-loader").css('display') !== 'none',
_allowDescription: false,
_textTimeout: null,
isVisible: function () {
return this._visible;
},
/**
* Show or hide full-page loading.
* @param loading
*/
show: function(loading) {
const loader = $("#fullscreen-loader");
if (this._visible === loading) return;
if (loading) {
loader.css('display', 'block');
// Make loading show
this._textTimeout = setTimeout(() => {
this._textTimeout = null;
this._allowDescription = true;
// Use the namespace's own text() — NOT jQuery's
// loader.text(true), which would overwrite the loader's
// children (spinner + title nodes) with the literal
// string "true", leaving the overlay up with no spinner.
if (this.isVisible()) this.text(true);
}, 3000);
} else {
if (this._textTimeout) {
clearTimeout(this._textTimeout);
this._textTimeout = null;
}
loader.css('display', 'none');
this.text(false);
}
this._visible = loading;
},
/**
* Show title for loading screen. Not performed if the loading screen is not visible.
* @param {boolean|string} titleText boolean to show/hide default text, string to show custom title
* @param {string} descriptionText optionally details
*/
text: function(titleText = true, descriptionText = "") {
if (!this.isVisible()) return;
const title = document.getElementById("fullscreen-loader-title");
const description = document.getElementById("fullscreen-loader-description");
if (!title || !description) return;
titleText = titleText === true ? title.innerText || $.t('messages.loading') : titleText;
title.innerText = titleText || "";
description.innerText = descriptionText;
if (this._allowDescription) {
if (titleText) title.classList.add('loading-text-style');
else title.classList.remove('loading-text-style');
if (descriptionText) description.classList.add('loading-text-style');
else description.classList.remove('loading-text-style');
}
}
},
/**
* Tutorial system
* @namespace USER_INTERFACE.Tutorials
*/
Tutorials: {
steps: [],
prerequisites: [],
_entries: [],
_modal: null,
running: false,
/**
* Tutorials don't lay out reliably below the same threshold the
* MobileBottomBar / collapsed AppBar swap kicks in at — when the
* viewport is mobile-shaped, the launcher shows an info notice
* instead of cards and `run` short-circuits with a toast. The
* gate reads the SAME knob the rest of the mobile UI uses, so a
* session that bumps `maxMobileWidthPx` also moves the tutorial
* gate in lock-step.
*/
_isMobile: function () {
const threshold = APPLICATION_CONTEXT.getOption("maxMobileWidthPx") || 900;
return window.innerWidth < threshold;
},
_ensureModal: function () {
if (this._modal) return this._modal;
this._modal = new UI.TutorialsModal({
onSelect: (index) => USER_INTERFACE.Tutorials.run(index),
onClose: () => {
USER_INTERFACE.Tutorials.running = false;
APPLICATION_CONTEXT.AppCookies.set('_shadersPin', 'false');
},
exitLabel: $.t('common.Exit'),
});
this._modal.mount(document.body);
this._modal.setEntries(this._entries);
return this._modal;
},
/**
* Open the tutorials selection screen
* @param {string} title title to show
* @param {string} description subtitle to show
*/
show: function(title = undefined, description = undefined) {
if (USER_INTERFACE.Errors.active || this.running) return;
if (!title) title = $.t('tutorials.menu.title');
if (!description) description = $.t('tutorials.menu.description');
const modal = this._ensureModal();
modal.setTitle(title);
modal.setDescription(description);
modal.setExitLabel($.t('common.Exit'));
// Swap the card grid for an info notice when the viewport is
// mobile-shaped; re-show the grid on desktop. The launcher
// re-evaluates on each open so resizing across the threshold
// and re-opening just works.
if (this._isMobile()) {
modal.setMobileNotice($.t('tutorials.notAvailableOnMobile'));
} else {
modal.clearMobileNotice();
}
modal.open();
this.running = true;
},
/**
* Hide Tutorials
*/
hide: function () {
this._hideImpl();
},
_hideImpl: function () {
// running flag + cookie cleanup is handled by the modal's onClose hook,
// so this works regardless of whether the close was triggered by the X
// button, backdrop, Exit button, or a programmatic hide() call.
this._modal?.close();
},
/**
* Register a tutorial in the {@link UI.TutorialsModal} launcher.
*
* Tutorials are driven by EnjoyHint under the hood — each step is
* an object whose **single** primary key is a jQuery selector
* string prefixed with an action verb (`"<action> <selector>"`),
* and whose value is the descriptive text shown next to the
* highlighted element. See `src/TUTORIALS.md` for the selector
* cookbook (including the `[id$="-…"]` viewer-agnostic pattern)
* and the full step grammar.
*
* @param {string} plugidId owner id; pass `""` for core. Used to
* tag the tutorial card with the plugin name + a CSS hook
* `${plugidId}-plugin-root` for scoped styling.
* @param {string} name short title shown on the tutorial card.
* @param {string} description one-line summary on the card.
* @param {string} icon Phosphor icon class (e.g. `"ph-compass"`)
* or a legacy Font Awesome class (`"fa-school"`). New code
* should prefer Phosphor. Defaults to `"fa-school"`.
* @param {Array<Object>} steps ordered step list. Each step has
* the shape `{ "<action> <selector>": "<HTML text>", runIf?: () => boolean }`.
* Supported actions: `next` (advance via the EnjoyHint NEXT
* button) and `click` (advance when the user actually clicks
* the selector — useful for opening a panel as part of the
* walk). Steps whose `runIf` returns false at run time are
* silently skipped. Step text is HTML-capable (the same
* sanitiser allowlist as `extra-tutorials` applies in xOpat
* builds that sanitise external input).
* @param {Function} [prerequisites] optional function executed
* when the tutorial actually starts (after the user clicks the
* card) — use it to put the UI into a known state (e.g. close
* floating panels) before EnjoyHint takes over.
*
* @example
* USER_INTERFACE.Tutorials.add(
* "",
* $.t('tutorials.basic.title'),
* $.t('tutorials.basic.description'),
* "ph-compass",
* [
* { 'next #viewer-container': $.t('tutorials.basic.viewer') },
* { 'click [id$="-right-menu-menu-b-opened-shaders"]': $.t('tutorials.basic.openLayers'),
* runIf: () => APPLICATION_CONTEXT.config.visualizations.length > 0 },
* ]
* );
*/
add: function(plugidId, name, description, icon, steps, prerequisites = undefined) {
const pluginName = pluginMeta(plugidId, "name");
this._entries.push({
name,
description,
icon: icon || "fa-school",
pluginName,
pluginRootClass: plugidId ? `${plugidId}-plugin-root` : "",
});
this.steps.push(steps);
this.prerequisites.push(prerequisites);
this._modal?.setEntries(this._entries);
},
/**
* Run tutorial
* @param {(number|Array)} ctx index to the attached tutorials list (internal use) or tutorials data
* see add(..) steps parameter
*/
run: function(ctx) {
// Single gate for every EnjoyHint launch path — launcher
// card click, extra-tutorials auto-run, direct programmatic
// call. Stops the tour before any DOM is allocated.
if (this._isMobile()) {
Dialogs.show($.t('tutorials.notAvailableOnMobile'), 4500, Dialogs.MSG_INFO);
return;
}
let prereq, data;
if (Number.isInteger(ctx)) {
if (ctx >= this.steps.length || ctx < 0) return;
prereq = this.prerequisites[ctx];
data = this.steps[ctx];
} else {
data = ctx;
}
//reset plugins visibility
$(".plugins-pin").each(function() {
let pin = $(this);
let container = pin.parents().eq(1).children().eq(2);
pin.removeClass('pressed');
container.removeClass('force-visible');
});
let enjoyhintInstance = new EnjoyHint({
onStart: function () {
window.addEventListener("resize", enjoyhintInstance.reRender, false);
window.addEventListener("click", enjoyhintInstance.rePaint, false);
if (typeof prereq === "function") prereq();
},
onEnd: function () {
window.removeEventListener("resize", enjoyhintInstance.reRender, false);
window.removeEventListener("click", enjoyhintInstance.rePaint, false);
},
onSkip: function () {
window.removeEventListener("resize", enjoyhintInstance.reRender, false);
window.removeEventListener("click", enjoyhintInstance.rePaint, false);
}
});
// VIEWER_MANAGER.viewerMenus is a Record<cellId, RightSideViewerMenu>,
// not an array — iterate values, not the object itself.
for (let viewerMenu of Object.values(VIEWER_MANAGER.viewerMenus)) {
viewerMenu?.menu?.focusAll?.();
}
enjoyhintInstance.set(data);
this.hide();
enjoyhintInstance.run();
this.running = false;
}
},
/**
* Add custom HTML to the DOM selector
* @param {UIElement} html to append
* @param {string} pluginId owner plugin ID
* @param {string} selector jquery selector where to append, default 'body'
*/
addHtml: function(html, pluginId, selector="body") {
try {
const jqNode = $(UI.BaseComponent.parseDomLikeItem(html));
jqNode.appendTo(selector).each((idx, element) => $(element).addClass(`${pluginId}-plugin-root`));
return true;
} catch (e) {
console.error("Could not attach custom HTML.", e);
return false;
}
},
/**
* Add custom HTML to the viewer-dependent context - it will be contained within the viewer area.
* This HTML IS NOT GUARANTEED TO BE PRESERVED when changing viewers.
* Plugins must listen to change in viewer events to update the UI if necessary,
* and should not rely on this HTML being persistent - when a viewer is gone, so is the HTML.
*
* @param {UIElement} html
* @param {string} pluginId
* @param {OpenSeadragon.Viewer|string} uniqueViewerId
* @return {boolean}
*/
addViewerHtml: function (html, pluginId, uniqueViewerId) {
try {
const jqNode = $(UI.BaseComponent.parseDomLikeItem(html));
const viewer = (uniqueViewerId instanceof OpenSeadragon.Viewer) ?
uniqueViewerId : VIEWER_MANAGER.getViewer(uniqueViewerId);
const cell = VIEWER_MANAGER.layout.findCellById(viewer?.id);
if (!cell) {
console.error("Could not find cell to attach to.");
return false;
}
let parent;
for (let child of cell.children) { if (child.dataset.kind === 'custom-viewer-html') { parent = child; break; } }
if (!parent) {
parent = van.tags.div({class: "absolute", style: "pointer-events: none; top: 0; left: 0; right: 0; bottom: 0; overflow: hidden;"});
parent.dataset.kind = 'custom-viewer-html';
cell.appendChild(parent);
} else {
for (let ch of parent.children) {
if (ch.dataset.id === pluginId) {
ch.remove();
}
}
}
// todo: viewer might get re-initialized, reusing the same cell - ensure we replace
jqNode.appendTo(parent).each((idx, element) => {
element.classList.add(`${pluginId}-plugin-root`);
element.style.pointerEvents = 'auto';
element.dataset.id = pluginId;
});
return true;
} catch (e) {
console.error("Could not attach custom HTML.", e);
return false;
}
},
};
let resizeTimer;
// resize handler to notify layout changes
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
// todo, either use window events directly, or convert to the openseadragon event system!
window.dispatchEvent(new CustomEvent('app:layout-change', {
detail: { width: window.innerWidth }
}));
}, 200);
});
// todo: maybe allow contextual override (based on http client)
window.XOpatSessionRecovery = {
isReloading: false,
// (_reason?: { status?: number; code?: string; message?: string; source?: string })
handle: (_reason) => {
if (this.isReloading) return true;
this.isReloading = true;
try { USER_INTERFACE.Loading.show(false); } catch (_) { }
// todo maybe do not force reload, make it optional?
const reload = () => {
try { window.location.reload(); } catch (_) { window.location.href = window.location.href; }
};
Dialogs.show($.t('error.sessionExpiredReloading'), 20000, Dialogs.MSG_ERR, {
queued: false,
onHide: reload,
});
window.setTimeout(reload, 2600);
return true;
}
};
}