zotero-plugin-toolkit
Version:
Toolkit for Zotero plugins
1,697 lines (1,687 loc) • 131 kB
JavaScript
import { __export } from "./chunk-Cl8Af3a2.js";
//#region package.json
var version = "5.1.0-beta.9";
//#endregion
//#region src/utils/debugBridge.ts
/**
* Debug bridge.
*
* @deprecated Since this is a temporary solution for debugging, it is not recommended to use.
* The zotero-plugin-scaffold no longer need this.
*
* @remarks
* Global variables: Zotero, window(main window).
*
* @example
* Run script directly. The `run` is URIencoded script.
*
* `zotero://ztoolkit-debug/?run=Zotero.getMainWindow().alert(%22HelloWorld!%22)&app=developer`
* @example
* Run script from file. The `file` is URIencoded path to js file starts with `file:///`
*
* `zotero://ztoolkit-debug/?file=file%3A%2F%2F%2FC%3A%2FUsers%2Fw_xia%2FDesktop%2Frun.js&app=developer`
*/
var DebugBridge = class DebugBridge {
static version = 2;
static passwordPref = "extensions.zotero.debug-bridge.password";
get version() {
return DebugBridge.version;
}
_disableDebugBridgePassword;
get disableDebugBridgePassword() {
return this._disableDebugBridgePassword;
}
set disableDebugBridgePassword(value) {
this._disableDebugBridgePassword = value;
}
get password() {
return BasicTool.getZotero().Prefs.get(DebugBridge.passwordPref, true);
}
set password(v) {
BasicTool.getZotero().Prefs.set(DebugBridge.passwordPref, v, true);
}
constructor() {
this._disableDebugBridgePassword = false;
this.initializeDebugBridge();
}
static setModule(instance) {
if (!instance.debugBridge?.version || instance.debugBridge.version < DebugBridge.version) instance.debugBridge = new DebugBridge();
}
initializeDebugBridge() {
const debugBridgeExtension = {
noContent: true,
doAction: async (uri) => {
const Zotero$1 = BasicTool.getZotero();
const window$1 = Zotero$1.getMainWindow();
const uriString = uri.spec.split("//").pop();
if (!uriString) return;
const params = {};
uriString.split("?").pop()?.split("&").forEach((p) => {
params[p.split("=")[0]] = decodeURIComponent(p.split("=")[1]);
});
const skipPasswordCheck = toolkitGlobal_default.getInstance()?.debugBridge.disableDebugBridgePassword;
let allowed = false;
if (skipPasswordCheck) allowed = true;
else if (typeof params.password === "undefined" && typeof this.password === "undefined") allowed = window$1.confirm(`External App ${params.app} wants to execute command without password.\nCommand:\n${(params.run || params.file || "").slice(0, 100)}\nIf you do not know what it is, please click Cancel to deny.`);
else allowed = this.password === params.password;
if (allowed) {
if (params.run) try {
const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor;
const f = new AsyncFunction("Zotero,window", params.run);
await f(Zotero$1, window$1);
} catch (e) {
Zotero$1.debug(e);
window$1.console.log(e);
}
if (params.file) try {
Services.scriptloader.loadSubScript(params.file, {
Zotero: Zotero$1,
window: window$1
});
} catch (e) {
Zotero$1.debug(e);
window$1.console.log(e);
}
}
},
newChannel(uri) {
this.doAction(uri);
}
};
Services.io.getProtocolHandler("zotero").wrappedJSObject._extensions["zotero://ztoolkit-debug"] = debugBridgeExtension;
}
};
//#endregion
//#region src/utils/pluginBridge.ts
/**
* Plugin bridge. Install plugin from zotero://plugin
*
* @deprecated Since this is a temporary solution for debugging, it is not recommended to use.
* The zotero-plugin-scaffold no longer need this.
*
* @example
* Install plugin from url, with minimal Zotero version requirement.
* ```text
* zotero://plugin/?action=install&url=https%3A%2F%2Fgithub.com%2FMuiseDestiny%2Fzotero-style%2Freleases%2Fdownload%2F3.0.5%2Fzotero-style.xpi&minVersion=6.999
* ```
*/
var PluginBridge = class PluginBridge {
static version = 1;
get version() {
return PluginBridge.version;
}
constructor() {
this.initializePluginBridge();
}
static setModule(instance) {
if (!instance.pluginBridge?.version || instance.pluginBridge.version < PluginBridge.version) instance.pluginBridge = new PluginBridge();
}
initializePluginBridge() {
const { AddonManager } = _importESModule("resource://gre/modules/AddonManager.sys.mjs");
const Zotero$1 = BasicTool.getZotero();
const pluginBridgeExtension = {
noContent: true,
doAction: async (uri) => {
try {
const uriString = uri.spec.split("//").pop();
if (!uriString) return;
const params = {};
uriString.split("?").pop()?.split("&").forEach((p) => {
params[p.split("=")[0]] = decodeURIComponent(p.split("=")[1]);
});
if (params.action === "install" && params.url) {
if (params.minVersion && Services.vc.compare(Zotero$1.version, params.minVersion) < 0 || params.maxVersion && Services.vc.compare(Zotero$1.version, params.maxVersion) > 0) throw new Error(`Plugin is not compatible with Zotero version ${Zotero$1.version}.The plugin requires Zotero version between ${params.minVersion} and ${params.maxVersion}.`);
const addon = await AddonManager.getInstallForURL(params.url);
if (addon && addon.state === AddonManager.STATE_AVAILABLE) {
addon.install();
hint("Plugin installed successfully.", true);
} else throw new Error(`Plugin ${params.url} is not available.`);
}
} catch (e) {
Zotero$1.logError(e);
hint(e.message, false);
}
},
newChannel(uri) {
this.doAction(uri);
}
};
Services.io.getProtocolHandler("zotero").wrappedJSObject._extensions["zotero://plugin"] = pluginBridgeExtension;
}
};
function hint(content, success) {
const progressWindow = new Zotero.ProgressWindow({ closeOnClick: true });
progressWindow.changeHeadline("Plugin Toolkit");
progressWindow.progress = new progressWindow.ItemProgress(success ? "chrome://zotero/skin/tick.png" : "chrome://zotero/skin/cross.png", content);
progressWindow.progress.setProgress(100);
progressWindow.show();
progressWindow.startCloseTimer(5e3);
}
//#endregion
//#region src/managers/toolkitGlobal.ts
/**
* The Singleton class of global parameters used by managers.
* @example `ToolkitGlobal.getInstance().itemTree.state`
*/
var ToolkitGlobal = class ToolkitGlobal {
debugBridge;
pluginBridge;
prompt;
currentWindow;
constructor() {
initializeModules(this);
this.currentWindow = BasicTool.getZotero().getMainWindow();
}
/**
* Get the global unique instance of `class ToolkitGlobal`.
* @returns An instance of `ToolkitGlobal`.
*/
static getInstance() {
let _Zotero;
try {
if (typeof Zotero !== "undefined") _Zotero = Zotero;
else _Zotero = BasicTool.getZotero();
} catch {}
if (!_Zotero) return void 0;
let requireInit = false;
if (!("_toolkitGlobal" in _Zotero)) {
_Zotero._toolkitGlobal = new ToolkitGlobal();
requireInit = true;
}
const currentGlobal = _Zotero._toolkitGlobal;
if (currentGlobal.currentWindow !== _Zotero.getMainWindow()) {
checkWindowDependentModules(currentGlobal);
requireInit = true;
}
if (requireInit) initializeModules(currentGlobal);
return currentGlobal;
}
};
/**
* Initialize global modules using the data of this toolkit build.
* Modules and their properties that do not exist will be updated.
* @param instance ToolkitGlobal instance
*/
function initializeModules(instance) {
new BasicTool().log("Initializing ToolkitGlobal modules");
setModule(instance, "prompt", {
_ready: false,
instance: void 0
});
DebugBridge.setModule(instance);
PluginBridge.setModule(instance);
}
function setModule(instance, key, module) {
if (!module) return;
if (!instance[key]) instance[key] = module;
for (const moduleKey in module) instance[key][moduleKey] ??= module[moduleKey];
}
function checkWindowDependentModules(instance) {
instance.currentWindow = BasicTool.getZotero().getMainWindow();
instance.prompt = void 0;
}
var toolkitGlobal_default = ToolkitGlobal;
//#endregion
//#region src/basic.ts
/**
* Basic APIs with Zotero 6 & newer (7) compatibility.
* See also https://www.zotero.org/support/dev/zotero_7_for_developers
*/
var BasicTool = class BasicTool {
/**
* configurations.
*/
_basicOptions;
_console;
/**
* @deprecated Use `patcherManager` instead.
*/
patchSign = "zotero-plugin-toolkit@3.0.0";
static _version = version;
/**
* Get version - checks subclass first, then falls back to parent
*/
get _version() {
return version;
}
get basicOptions() {
return this._basicOptions;
}
/**
*
* @param data Pass an BasicTool instance to copy its options.
*/
constructor(data) {
this._basicOptions = {
log: {
_type: "toolkitlog",
disableConsole: false,
disableZLog: false,
prefix: ""
},
get debug() {
if (this._debug) return this._debug;
this._debug = toolkitGlobal_default.getInstance()?.debugBridge || {
disableDebugBridgePassword: false,
password: ""
};
return this._debug;
},
api: { pluginID: "zotero-plugin-toolkit@windingwind.com" },
listeners: {
callbacks: {
onMainWindowLoad: /* @__PURE__ */ new Set(),
onMainWindowUnload: /* @__PURE__ */ new Set(),
onPluginUnload: /* @__PURE__ */ new Set()
},
_mainWindow: void 0,
_plugin: void 0
}
};
try {
if (typeof globalThis.ChromeUtils?.importESModule !== "undefined" || typeof globalThis.ChromeUtils?.import !== "undefined") {
const { ConsoleAPI } = _importESModule("resource://gre/modules/Console.sys.mjs");
this._console = new ConsoleAPI({ consoleID: `${this._basicOptions.api.pluginID}-${Date.now()}` });
}
} catch {}
this.updateOptions(data);
}
getGlobal(k) {
if (typeof globalThis[k] !== "undefined") return globalThis[k];
const _Zotero = BasicTool.getZotero();
try {
const window$1 = _Zotero.getMainWindow();
switch (k) {
case "Zotero":
case "zotero": return _Zotero;
case "window": return window$1;
case "windows": return _Zotero.getMainWindows();
case "document": return window$1.document;
case "ZoteroPane":
case "ZoteroPane_Local": return _Zotero.getActiveZoteroPane();
default: return window$1[k];
}
} catch (e) {
Zotero.logError(e);
}
}
/**
* If it's an XUL element
* @param elem
*/
isXULElement(elem) {
return elem.namespaceURI === "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
}
/**
* Create an XUL element
*
* For Zotero 6, use `createElementNS`;
*
* For Zotero 7+, use `createXULElement`.
* @param doc
* @param type
* @example
* Create a `<menuitem>`:
* ```ts
* const compat = new ZoteroCompat();
* const doc = compat.getWindow().document;
* const elem = compat.createXULElement(doc, "menuitem");
* ```
*/
createXULElement(doc, type) {
return doc.createXULElement(type);
}
/**
* Output to both Zotero.debug and console.log
* @param data e.g. string, number, object, ...
*/
log(...data) {
if (data.length === 0) return;
let _Zotero;
try {
if (typeof Zotero !== "undefined") _Zotero = Zotero;
else _Zotero = BasicTool.getZotero();
} catch {}
let options;
if (data[data.length - 1]?._type === "toolkitlog") options = data.pop();
else options = this._basicOptions.log;
try {
if (options.prefix) data.splice(0, 0, options.prefix);
if (!options.disableConsole) {
let _console;
if (typeof console !== "undefined") _console = console;
else if (_Zotero) _console = _Zotero.getMainWindow()?.console;
if (!_console) {
if (!this._console) return;
_console = this._console;
}
if (_console.groupCollapsed) _console.groupCollapsed(...data);
else _console.group(...data);
_console.trace();
_console.groupEnd();
}
if (!options.disableZLog) {
if (typeof _Zotero === "undefined") return;
_Zotero.debug(data.map((d) => {
try {
return typeof d === "object" ? JSON.stringify(d) : String(d);
} catch {
_Zotero.debug(d);
return "";
}
}).join("\n"));
}
} catch (e) {
if (_Zotero) Zotero.logError(e);
else console.error(e);
}
}
/**
* Patch a function
* @deprecated Use {@link PatchHelper} instead.
* @param object The owner of the function
* @param funcSign The signature of the function(function name)
* @param ownerSign The signature of patch owner to avoid patching again
* @param patcher The new wrapper of the patched function
*/
patch(object, funcSign, ownerSign, patcher) {
if (object[funcSign][ownerSign]) throw new Error(`${String(funcSign)} re-patched`);
this.log("patching", funcSign, `by ${ownerSign}`);
object[funcSign] = patcher(object[funcSign]);
object[funcSign][ownerSign] = true;
}
/**
* Add a Zotero event listener callback
* @param type Event type
* @param callback Event callback
*/
addListenerCallback(type, callback) {
if (["onMainWindowLoad", "onMainWindowUnload"].includes(type)) this._ensureMainWindowListener();
if (type === "onPluginUnload") this._ensurePluginListener();
this._basicOptions.listeners.callbacks[type].add(callback);
}
/**
* Remove a Zotero event listener callback
* @param type Event type
* @param callback Event callback
*/
removeListenerCallback(type, callback) {
this._basicOptions.listeners.callbacks[type].delete(callback);
this._ensureRemoveListener();
}
/**
* Remove all Zotero event listener callbacks when the last callback is removed.
*/
_ensureRemoveListener() {
const { listeners } = this._basicOptions;
if (listeners._mainWindow && listeners.callbacks.onMainWindowLoad.size === 0 && listeners.callbacks.onMainWindowUnload.size === 0) {
Services.wm.removeListener(listeners._mainWindow);
delete listeners._mainWindow;
}
if (listeners._plugin && listeners.callbacks.onPluginUnload.size === 0) {
Zotero.Plugins.removeObserver(listeners._plugin);
delete listeners._plugin;
}
}
/**
* Ensure the main window listener is registered.
*/
_ensureMainWindowListener() {
if (this._basicOptions.listeners._mainWindow) return;
const mainWindowListener = {
onOpenWindow: (xulWindow) => {
const domWindow = xulWindow.docShell.domWindow;
const onload = async () => {
domWindow.removeEventListener("load", onload, false);
if (domWindow.location.href !== "chrome://zotero/content/zoteroPane.xhtml") return;
for (const cbk of this._basicOptions.listeners.callbacks.onMainWindowLoad) try {
cbk(domWindow);
} catch (e) {
this.log(e);
}
};
domWindow.addEventListener("load", () => onload(), false);
},
onCloseWindow: async (xulWindow) => {
const domWindow = xulWindow.docShell.domWindow;
if (domWindow.location.href !== "chrome://zotero/content/zoteroPane.xhtml") return;
for (const cbk of this._basicOptions.listeners.callbacks.onMainWindowUnload) try {
cbk(domWindow);
} catch (e) {
this.log(e);
}
}
};
this._basicOptions.listeners._mainWindow = mainWindowListener;
Services.wm.addListener(mainWindowListener);
}
/**
* Ensure the plugin listener is registered.
*/
_ensurePluginListener() {
if (this._basicOptions.listeners._plugin) return;
const pluginListener = { shutdown: (...args) => {
for (const cbk of this._basicOptions.listeners.callbacks.onPluginUnload) try {
cbk(...args);
} catch (e) {
this.log(e);
}
} };
this._basicOptions.listeners._plugin = pluginListener;
Zotero.Plugins.addObserver(pluginListener);
}
updateOptions(source) {
if (!source) return this;
if (source instanceof BasicTool) this._basicOptions = source._basicOptions;
else this._basicOptions = source;
return this;
}
static getZotero() {
if (typeof Zotero !== "undefined") return Zotero;
const { Zotero: _Zotero } = ChromeUtils.importESModule("chrome://zotero/content/zotero.mjs");
return _Zotero;
}
};
var ManagerTool = class extends BasicTool {
_ensureAutoUnregisterAll() {
this.addListenerCallback("onPluginUnload", (params, _reason) => {
if (params.id !== this.basicOptions.api.pluginID) return;
this.unregisterAll();
});
}
};
function unregister(tools) {
Object.values(tools).forEach((tool) => {
if (tool instanceof ManagerTool || typeof tool?.unregisterAll === "function") tool.unregisterAll();
});
}
function makeHelperTool(cls, options) {
return new Proxy(cls, { construct(target, args) {
const _origin = new cls(...args);
if (_origin instanceof BasicTool) _origin.updateOptions(options);
else _origin._version = BasicTool._version;
return _origin;
} });
}
function _importESModule(path) {
if (typeof ChromeUtils.import === "undefined") return ChromeUtils.importESModule(path, { global: "contextual" });
if (path.endsWith(".sys.mjs")) path = path.replace(/\.sys\.mjs$/, ".jsm");
return ChromeUtils.import(path);
}
//#endregion
//#region src/helpers/clipboard.ts
/**
* Copy helper for text/richtext/image.
*
* @example
* Copy plain text
* ```ts
* new ClipboardHelper().addText("plain", "text/unicode").copy();
* ```
* @example
* Copy plain text & rich text
* ```ts
* new ClipboardHelper().addText("plain", "text/unicode")
* .addText("<h1>rich text</h1>", "text/html")
* .copy();
* ```
* @example
* Copy plain text, rich text & image
* ```ts
* new ClipboardHelper().addText("plain", "text/unicode")
* .addText("<h1>rich text</h1>", "text/html")
* .addImage("data:image/png;base64,...")
* .copy();
* ```
*/
var ClipboardHelper = class extends BasicTool {
transferable;
clipboardService;
filePath = "";
constructor() {
super();
this.transferable = Components.classes["@mozilla.org/widget/transferable;1"].createInstance(Components.interfaces.nsITransferable);
this.clipboardService = Components.classes["@mozilla.org/widget/clipboard;1"].getService(Components.interfaces.nsIClipboard);
this.transferable.init(null);
}
addText(source, type = "text/plain") {
const str = Components.classes["@mozilla.org/supports-string;1"].createInstance(Components.interfaces.nsISupportsString);
str.data = source;
if (type === "text/unicode") type = "text/plain";
this.transferable.addDataFlavor(type);
this.transferable.setTransferData(type, str, source.length * 2);
return this;
}
addImage(source) {
const parts = source.split(",");
if (!parts[0].includes("base64")) return this;
const mime = parts[0].match(/:(.*?);/)[1];
const bstr = this.getGlobal("window").atob(parts[1]);
let n = bstr.length;
const u8arr = new Uint8Array(n);
while (n--) u8arr[n] = bstr.charCodeAt(n);
const imgTools = Components.classes["@mozilla.org/image/tools;1"].getService(Components.interfaces.imgITools);
let mimeType;
let img;
if (this.getGlobal("Zotero").platformMajorVersion >= 102) {
img = imgTools.decodeImageFromArrayBuffer(u8arr.buffer, mime);
mimeType = "application/x-moz-nativeimage";
} else {
mimeType = `image/png`;
img = Components.classes["@mozilla.org/supports-interface-pointer;1"].createInstance(Components.interfaces.nsISupportsInterfacePointer);
img.data = imgTools.decodeImageFromArrayBuffer(u8arr.buffer, mimeType);
}
this.transferable.addDataFlavor(mimeType);
this.transferable.setTransferData(mimeType, img, 0);
return this;
}
addFile(path) {
const file = Components.classes["@mozilla.org/file/local;1"].createInstance(Components.interfaces.nsIFile);
file.initWithPath(path);
this.transferable.addDataFlavor("application/x-moz-file");
this.transferable.setTransferData("application/x-moz-file", file);
this.filePath = path;
return this;
}
copy() {
try {
this.clipboardService.setData(this.transferable, null, Components.interfaces.nsIClipboard.kGlobalClipboard);
} catch (e) {
if (this.filePath && Zotero.isMac) Zotero.Utilities.Internal.exec(`/usr/bin/osascript`, [`-e`, `set the clipboard to POSIX file "${this.filePath}"`]);
else throw e;
}
return this;
}
};
//#endregion
//#region src/tools/ui.ts
/**
* UI APIs. Create elements and manage them.
*/
var UITool = class extends BasicTool {
get basicOptions() {
return this._basicOptions;
}
/**
* Store elements created with this instance
*
* @remarks
* > What is this for?
*
* In bootstrap plugins, elements must be manually maintained and removed on exiting.
*
* This API does this for you.
*/
elementCache;
constructor(base) {
super(base);
this.elementCache = [];
if (!this._basicOptions.ui) this._basicOptions.ui = {
enableElementRecord: true,
enableElementJSONLog: false,
enableElementDOMLog: true
};
}
/**
* Remove all elements created by `createElement`.
*
* @remarks
* > What is this for?
*
* In bootstrap plugins, elements must be manually maintained and removed on exiting.
*
* This API does this for you.
*/
unregisterAll() {
this.elementCache.forEach((e) => {
try {
e?.deref()?.remove();
} catch (e$1) {
this.log(e$1);
}
});
}
createElement(...args) {
const doc = args[0];
const tagName = args[1].toLowerCase();
let props = args[2] || {};
if (!tagName) return;
if (typeof args[2] === "string") props = {
namespace: args[2],
enableElementRecord: args[3]
};
if (typeof props.enableElementJSONLog !== "undefined" && props.enableElementJSONLog || this.basicOptions.ui.enableElementJSONLog) this.log(props);
props.properties = props.properties || props.directAttributes;
props.children = props.children || props.subElementOptions;
let elem;
if (tagName === "fragment") {
const fragElem = doc.createDocumentFragment();
elem = fragElem;
} else {
let realElem = props.id && (props.checkExistenceParent ? props.checkExistenceParent : doc).querySelector(`#${props.id}`);
if (realElem && props.ignoreIfExists) return realElem;
if (realElem && props.removeIfExists) {
realElem.remove();
realElem = void 0;
}
if (props.customCheck && !props.customCheck(doc, props)) return void 0;
if (!realElem || !props.skipIfExists) {
let namespace = props.namespace;
if (!namespace) {
const mightHTML = HTMLElementTagNames.includes(tagName);
const mightXUL = XULElementTagNames.includes(tagName);
const mightSVG = SVGElementTagNames.includes(tagName);
if (Number(mightHTML) + Number(mightXUL) + Number(mightSVG) > 1) this.log(`[Warning] Creating element ${tagName} with no namespace specified. Found multiply namespace matches.`);
if (mightHTML) namespace = "html";
else if (mightXUL) namespace = "xul";
else if (mightSVG) namespace = "svg";
else namespace = "html";
}
if (namespace === "xul") realElem = this.createXULElement(doc, tagName);
else realElem = doc.createElementNS({
html: "http://www.w3.org/1999/xhtml",
svg: "http://www.w3.org/2000/svg"
}[namespace], tagName);
if (typeof props.enableElementRecord !== "undefined" ? props.enableElementRecord : this.basicOptions.ui.enableElementRecord) this.elementCache.push(new WeakRef(realElem));
}
if (props.id) realElem.id = props.id;
if (props.styles && Object.keys(props.styles).length) Object.keys(props.styles).forEach((k) => {
const v = props.styles[k];
typeof v !== "undefined" && (realElem.style[k] = v);
});
if (props.properties && Object.keys(props.properties).length) Object.keys(props.properties).forEach((k) => {
const v = props.properties[k];
typeof v !== "undefined" && (realElem[k] = v);
});
if (props.attributes && Object.keys(props.attributes).length) Object.keys(props.attributes).forEach((k) => {
const v = props.attributes[k];
typeof v !== "undefined" && realElem.setAttribute(k, String(v));
});
if (props.classList?.length) realElem.classList.add(...props.classList);
if (props.listeners?.length) props.listeners.forEach(({ type, listener, options }) => {
listener && realElem.addEventListener(type, listener, options);
});
elem = realElem;
}
if (props.children?.length) {
const subElements = props.children.map((childProps) => {
childProps.namespace = childProps.namespace || props.namespace;
return this.createElement(doc, childProps.tag, childProps);
}).filter((e) => e);
elem.append(...subElements);
}
if (typeof props.enableElementDOMLog !== "undefined" ? props.enableElementDOMLog : this.basicOptions.ui.enableElementDOMLog) this.log(elem);
return elem;
}
/**
* Append element(s) to a node.
* @param properties See {@link ElementProps}
* @param container The parent node to append to.
* @returns A Node that is the appended child (aChild),
* except when aChild is a DocumentFragment,
* in which case the empty DocumentFragment is returned.
*/
appendElement(properties, container) {
return container.appendChild(this.createElement(container.ownerDocument, properties.tag, properties));
}
/**
* Inserts a node before a reference node as a child of its parent node.
* @param properties See {@link ElementProps}
* @param referenceNode The node before which newNode is inserted.
* @returns Node
*/
insertElementBefore(properties, referenceNode) {
if (referenceNode.parentNode) return referenceNode.parentNode.insertBefore(this.createElement(referenceNode.ownerDocument, properties.tag, properties), referenceNode);
else this.log(`${referenceNode.tagName} has no parent, cannot insert ${properties.tag}`);
}
/**
* Replace oldNode with a new one.
* @param properties See {@link ElementProps}
* @param oldNode The child to be replaced.
* @returns The replaced Node. This is the same node as oldChild.
*/
replaceElement(properties, oldNode) {
if (oldNode.parentNode) return oldNode.parentNode.replaceChild(this.createElement(oldNode.ownerDocument, properties.tag, properties), oldNode);
else this.log(`${oldNode.tagName} has no parent, cannot replace it with ${properties.tag}`);
}
/**
* Parse XHTML to XUL fragment. For Zotero 6.
*
* To load preferences from a Zotero 7's `.xhtml`, use this method to parse it.
* @param str xhtml raw text
* @param entities dtd file list ("chrome://xxx.dtd")
* @param defaultXUL true for default XUL namespace
*/
parseXHTMLToFragment(str, entities = [], defaultXUL = true) {
const parser = new DOMParser();
const xulns = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
const htmlns = "http://www.w3.org/1999/xhtml";
const wrappedStr = `${entities.length ? `<!DOCTYPE bindings [ ${entities.reduce((preamble, url, index) => {
return `${preamble}<!ENTITY % _dtd-${index} SYSTEM "${url}"> %_dtd-${index}; `;
}, "")}]>` : ""}
<html:div xmlns="${defaultXUL ? xulns : htmlns}"
xmlns:xul="${xulns}" xmlns:html="${htmlns}">
${str}
</html:div>`;
this.log(wrappedStr, parser);
const doc = parser.parseFromString(wrappedStr, "text/xml");
this.log(doc);
if (doc.documentElement.localName === "parsererror") throw new Error("not well-formed XHTML");
const range = doc.createRange();
range.selectNodeContents(doc.querySelector("div"));
return range.extractContents();
}
};
const HTMLElementTagNames = [
"a",
"abbr",
"address",
"area",
"article",
"aside",
"audio",
"b",
"base",
"bdi",
"bdo",
"blockquote",
"body",
"br",
"button",
"canvas",
"caption",
"cite",
"code",
"col",
"colgroup",
"data",
"datalist",
"dd",
"del",
"details",
"dfn",
"dialog",
"div",
"dl",
"dt",
"em",
"embed",
"fieldset",
"figcaption",
"figure",
"footer",
"form",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"head",
"header",
"hgroup",
"hr",
"html",
"i",
"iframe",
"img",
"input",
"ins",
"kbd",
"label",
"legend",
"li",
"link",
"main",
"map",
"mark",
"menu",
"meta",
"meter",
"nav",
"noscript",
"object",
"ol",
"optgroup",
"option",
"output",
"p",
"picture",
"pre",
"progress",
"q",
"rp",
"rt",
"ruby",
"s",
"samp",
"script",
"section",
"select",
"slot",
"small",
"source",
"span",
"strong",
"style",
"sub",
"summary",
"sup",
"table",
"tbody",
"td",
"template",
"textarea",
"tfoot",
"th",
"thead",
"time",
"title",
"tr",
"track",
"u",
"ul",
"var",
"video",
"wbr"
];
const XULElementTagNames = [
"action",
"arrowscrollbox",
"bbox",
"binding",
"bindings",
"box",
"broadcaster",
"broadcasterset",
"button",
"browser",
"checkbox",
"caption",
"colorpicker",
"column",
"columns",
"commandset",
"command",
"conditions",
"content",
"deck",
"description",
"dialog",
"dialogheader",
"editor",
"grid",
"grippy",
"groupbox",
"hbox",
"iframe",
"image",
"key",
"keyset",
"label",
"listbox",
"listcell",
"listcol",
"listcols",
"listhead",
"listheader",
"listitem",
"member",
"menu",
"menubar",
"menuitem",
"menulist",
"menupopup",
"menuseparator",
"observes",
"overlay",
"page",
"popup",
"popupset",
"preference",
"preferences",
"prefpane",
"prefwindow",
"progressmeter",
"radio",
"radiogroup",
"resizer",
"richlistbox",
"richlistitem",
"row",
"rows",
"rule",
"script",
"scrollbar",
"scrollbox",
"scrollcorner",
"separator",
"spacer",
"splitter",
"stack",
"statusbar",
"statusbarpanel",
"stringbundle",
"stringbundleset",
"tab",
"tabbrowser",
"tabbox",
"tabpanel",
"tabpanels",
"tabs",
"template",
"textnode",
"textbox",
"titlebar",
"toolbar",
"toolbarbutton",
"toolbargrippy",
"toolbaritem",
"toolbarpalette",
"toolbarseparator",
"toolbarset",
"toolbarspacer",
"toolbarspring",
"toolbox",
"tooltip",
"tree",
"treecell",
"treechildren",
"treecol",
"treecols",
"treeitem",
"treerow",
"treeseparator",
"triple",
"vbox",
"window",
"wizard",
"wizardpage"
];
const SVGElementTagNames = [
"a",
"animate",
"animateMotion",
"animateTransform",
"circle",
"clipPath",
"defs",
"desc",
"ellipse",
"feBlend",
"feColorMatrix",
"feComponentTransfer",
"feComposite",
"feConvolveMatrix",
"feDiffuseLighting",
"feDisplacementMap",
"feDistantLight",
"feDropShadow",
"feFlood",
"feFuncA",
"feFuncB",
"feFuncG",
"feFuncR",
"feGaussianBlur",
"feImage",
"feMerge",
"feMergeNode",
"feMorphology",
"feOffset",
"fePointLight",
"feSpecularLighting",
"feSpotLight",
"feTile",
"feTurbulence",
"filter",
"foreignObject",
"g",
"image",
"line",
"linearGradient",
"marker",
"mask",
"metadata",
"mpath",
"path",
"pattern",
"polygon",
"polyline",
"radialGradient",
"rect",
"script",
"set",
"stop",
"style",
"svg",
"switch",
"symbol",
"text",
"textPath",
"title",
"tspan",
"use",
"view"
];
//#endregion
//#region src/helpers/dialog.ts
/**
* Dialog window helper. A superset of XUL dialog.
*/
var DialogHelper = class extends UITool {
/**
* Passed to dialog window for data-binding and lifecycle controls. See {@link DialogHelper.setDialogData}
*/
dialogData;
/**
* Dialog window instance
*/
window;
elementProps;
/**
* Create a dialog helper with row \* column grids.
* @param row
* @param column
*/
constructor(row, column) {
super();
if (row <= 0 || column <= 0) throw new Error(`row and column must be positive integers.`);
this.elementProps = {
tag: "vbox",
attributes: { flex: 1 },
styles: {
width: "100%",
height: "100%"
},
children: []
};
for (let i = 0; i < Math.max(row, 1); i++) {
this.elementProps.children.push({
tag: "hbox",
attributes: { flex: 1 },
children: []
});
for (let j = 0; j < Math.max(column, 1); j++) this.elementProps.children[i].children.push({
tag: "vbox",
attributes: { flex: 1 },
children: []
});
}
this.elementProps.children.push({
tag: "hbox",
attributes: {
flex: 0,
pack: "end"
},
children: []
});
this.dialogData = {};
}
/**
* Add a cell at (row, column). Index starts from 0.
* @param row
* @param column
* @param elementProps Cell element props. See {@link ElementProps}
* @param cellFlex If the cell is flex. Default true.
*/
addCell(row, column, elementProps, cellFlex = true) {
if (row >= this.elementProps.children.length || column >= this.elementProps.children[row].children.length) throw new Error(`Cell index (${row}, ${column}) is invalid, maximum (${this.elementProps.children.length}, ${this.elementProps.children[0].children.length})`);
this.elementProps.children[row].children[column].children = [elementProps];
this.elementProps.children[row].children[column].attributes.flex = cellFlex ? 1 : 0;
return this;
}
/**
* Add a control button to the bottom of the dialog.
* @param label Button label
* @param id Button id.
* The corresponding id of the last button user clicks before window exit will be set to `dialogData._lastButtonId`.
* @param options Options
* @param [options.noClose] Don't close window when clicking this button.
* @param [options.callback] Callback of button click event.
*/
addButton(label, id, options = {}) {
id = id || `btn-${Zotero.Utilities.randomString()}-${(/* @__PURE__ */ new Date()).getTime()}`;
this.elementProps.children[this.elementProps.children.length - 1].children.push({
tag: "vbox",
styles: { margin: "10px" },
children: [{
tag: "button",
namespace: "html",
id,
attributes: {
type: "button",
"data-l10n-id": label
},
properties: { innerHTML: label },
listeners: [{
type: "click",
listener: (e) => {
this.dialogData._lastButtonId = id;
if (options.callback) options.callback(e);
if (!options.noClose) this.window.close();
}
}]
}]
});
return this;
}
/**
* Dialog data.
* @remarks
* This object is passed to the dialog window.
*
* The control button id is in `dialogData._lastButtonId`;
*
* The data-binding values are in `dialogData`.
* ```ts
* interface DialogData {
* [key: string | number | symbol]: any;
* loadLock?: { promise: Promise<void>; resolve: () => void; isResolved: () => boolean }; // resolve after window load (auto-generated)
* loadCallback?: Function; // called after window load
* unloadLock?: { promise: Promise<void>; resolve: () => void }; // resolve after window unload (auto-generated)
* unloadCallback?: Function; // called after window unload
* beforeUnloadCallback?: Function; // called before window unload when elements are accessable.
* }
* ```
* @param dialogData
*/
setDialogData(dialogData) {
this.dialogData = dialogData;
return this;
}
/**
* Open the dialog
* @param title Window title
* @param windowFeatures
* @param windowFeatures.width Ignored if fitContent is `true`.
* @param windowFeatures.height Ignored if fitContent is `true`.
* @param windowFeatures.left
* @param windowFeatures.top
* @param windowFeatures.centerscreen Open window at the center of screen.
* @param windowFeatures.resizable If window is resizable.
* @param windowFeatures.fitContent Resize the window to content size after elements are loaded.
* @param windowFeatures.noDialogMode Dialog mode window only has a close button. Set `true` to make maximize and minimize button visible.
* @param windowFeatures.alwaysRaised Is the window always at the top.
*/
open(title, windowFeatures = {
centerscreen: true,
resizable: true,
fitContent: true
}) {
this.window = openDialog(this, `dialog-${Zotero.Utilities.randomString()}-${(/* @__PURE__ */ new Date()).getTime()}`, title, this.elementProps, this.dialogData, windowFeatures);
return this;
}
};
function openDialog(dialogHelper, targetId, title, elementProps, dialogData, windowFeatures = {
centerscreen: true,
resizable: true,
fitContent: true
}) {
dialogData = dialogData || {};
if (!dialogData.loadLock) {
let loadResolve;
let isLoadResolved = false;
const loadPromise = new Promise((resolve) => {
loadResolve = resolve;
});
loadPromise.then(() => {
isLoadResolved = true;
});
dialogData.loadLock = {
promise: loadPromise,
resolve: loadResolve,
isResolved: () => isLoadResolved
};
}
if (!dialogData.unloadLock) {
let unloadResolve;
const unloadPromise = new Promise((resolve) => {
unloadResolve = resolve;
});
dialogData.unloadLock = {
promise: unloadPromise,
resolve: unloadResolve
};
}
let featureString = `resizable=${windowFeatures.resizable ? "yes" : "no"},`;
if (windowFeatures.width || windowFeatures.height) featureString += `width=${windowFeatures.width || 100},height=${windowFeatures.height || 100},`;
if (windowFeatures.left) featureString += `left=${windowFeatures.left},`;
if (windowFeatures.top) featureString += `top=${windowFeatures.top},`;
if (windowFeatures.centerscreen) featureString += "centerscreen,";
if (windowFeatures.noDialogMode) featureString += "dialog=no,";
if (windowFeatures.alwaysRaised) featureString += "alwaysRaised=yes,";
const win = dialogHelper.getGlobal("openDialog")("about:blank", targetId || "_blank", featureString, dialogData);
dialogData.loadLock?.promise.then(() => {
win.document.head.appendChild(dialogHelper.createElement(win.document, "title", {
properties: { innerText: title },
attributes: { "data-l10n-id": title }
}));
let l10nFiles = dialogData.l10nFiles || [];
if (typeof l10nFiles === "string") l10nFiles = [l10nFiles];
l10nFiles.forEach((file) => {
win.document.head.appendChild(dialogHelper.createElement(win.document, "link", { properties: {
rel: "localization",
href: file
} }));
});
dialogHelper.appendElement({
tag: "fragment",
children: [
{
tag: "style",
properties: { innerHTML: style }
},
{
tag: "link",
properties: {
rel: "stylesheet",
href: "chrome://global/skin/global.css"
}
},
{
tag: "link",
properties: {
rel: "stylesheet",
href: "chrome://zotero-platform/content/zotero.css"
}
}
]
}, win.document.head);
replaceElement(elementProps, dialogHelper);
win.document.body.appendChild(dialogHelper.createElement(win.document, "fragment", { children: [elementProps] }));
Array.from(win.document.querySelectorAll("*[data-bind]")).forEach((elem) => {
const bindKey = elem.getAttribute("data-bind");
const bindAttr = elem.getAttribute("data-attr");
const bindProp = elem.getAttribute("data-prop");
if (bindKey && dialogData && dialogData[bindKey]) if (bindProp) elem[bindProp] = dialogData[bindKey];
else elem.setAttribute(bindAttr || "value", dialogData[bindKey]);
});
if (windowFeatures.fitContent) setTimeout(() => {
win.sizeToContent();
}, 300);
win.focus();
}).then(() => {
dialogData?.loadCallback && dialogData.loadCallback();
});
dialogData.unloadLock?.promise.then(() => {
dialogData?.unloadCallback && dialogData.unloadCallback();
});
win.addEventListener("DOMContentLoaded", function onWindowLoad(_ev) {
win.arguments[0]?.loadLock?.resolve();
win.removeEventListener("DOMContentLoaded", onWindowLoad, false);
}, false);
win.addEventListener("beforeunload", function onWindowBeforeUnload(_ev) {
Array.from(win.document.querySelectorAll("*[data-bind]")).forEach((elem) => {
const dialogData$1 = this.window.arguments[0];
const bindKey = elem.getAttribute("data-bind");
const bindAttr = elem.getAttribute("data-attr");
const bindProp = elem.getAttribute("data-prop");
if (bindKey && dialogData$1) if (bindProp) dialogData$1[bindKey] = elem[bindProp];
else dialogData$1[bindKey] = elem.getAttribute(bindAttr || "value");
});
this.window.removeEventListener("beforeunload", onWindowBeforeUnload, false);
dialogData?.beforeUnloadCallback && dialogData.beforeUnloadCallback();
});
win.addEventListener("unload", function onWindowUnload(_ev) {
if (!this.window.arguments[0]?.loadLock?.isResolved()) return;
this.window.arguments[0]?.unloadLock?.resolve();
this.window.removeEventListener("unload", onWindowUnload, false);
});
if (win.document.readyState === "complete") win.arguments[0]?.loadLock?.resolve();
return win;
}
function replaceElement(elementProps, uiTool) {
let checkChildren = true;
if (elementProps.tag === "select") {
let is140 = false;
try {
is140 = Number.parseInt(Services.appinfo.platformVersion.match(/^\d+/)[0]) >= 140;
} catch {
is140 = false;
}
if (!is140) {
checkChildren = false;
const customSelectProps = {
tag: "div",
classList: ["dropdown"],
listeners: [{
type: "mouseleave",
listener: (ev) => {
const select = ev.target.querySelector("select");
select?.blur();
}
}],
children: [Object.assign({}, elementProps, {
tag: "select",
listeners: [{
type: "focus",
listener: (ev) => {
const select = ev.target;
const dropdown = select.parentElement?.querySelector(".dropdown-content");
dropdown && (dropdown.style.display = "block");
select.setAttribute("focus", "true");
}
}, {
type: "blur",
listener: (ev) => {
const select = ev.target;
const dropdown = select.parentElement?.querySelector(".dropdown-content");
dropdown && (dropdown.style.display = "none");
select.removeAttribute("focus");
}
}]
}), {
tag: "div",
classList: ["dropdown-content"],
children: elementProps.children?.map((option) => ({
tag: "p",
attributes: { value: option.properties?.value },
properties: { innerHTML: option.properties?.innerHTML || option.properties?.textContent },
classList: ["dropdown-item"],
listeners: [{
type: "click",
listener: (ev) => {
const select = ev.target.parentElement?.previousElementSibling;
select && (select.value = ev.target.getAttribute("value") || "");
select?.blur();
}
}]
}))
}]
};
for (const key in elementProps) delete elementProps[key];
Object.assign(elementProps, customSelectProps);
} else {
const children = elementProps.children || [];
const randomString = CSS.escape(`${Zotero.Utilities.randomString()}-${(/* @__PURE__ */ new Date()).getTime()}`);
if (!elementProps.id) elementProps.id = `select-${randomString}`;
const selectId = elementProps.id;
const popupId = `popup-${randomString}`;
const popup = uiTool.appendElement({
tag: "menupopup",
namespace: "xul",
id: popupId,
children: children.map((option) => ({
tag: "menuitem",
attributes: {
value: option.properties?.value,
label: option.properties?.innerHTML || option.properties?.textContent
}
})),
listeners: [{
type: "command",
listener: (ev) => {
if (ev.target?.tagName !== "menuitem") return;
const select = uiTool.window.document.getElementById(selectId);
const menuitem = ev.target;
if (select) {
select.value = menuitem.getAttribute("value") || "";
select.blur();
}
popup.hidePopup();
}
}]
}, uiTool.window.document.body);
if (!elementProps.listeners) elementProps.listeners = [];
elementProps.listeners.push(...[{
type: "click",
listener: (ev) => {
const select = ev.target;
const rect = select.getBoundingClientRect();
let left = rect.left + uiTool.window.scrollX;
let top = rect.bottom + uiTool.window.scrollY;
if (uiTool.getGlobal("Zotero").isMac) {
left += uiTool.window.screenLeft;
top += uiTool.window.screenTop + rect.height;
}
fixMenuPopup(popup, uiTool);
popup.openPopup(null, "", left, top, false, false);
select.setAttribute("focus", "true");
}
}]);
}
} else if (elementProps.tag === "a") {
const href = elementProps?.properties?.href || "";
elementProps.properties ??= {};
elementProps.properties.href = "javascript:void(0);";
elementProps.attributes ??= {};
elementProps.attributes["zotero-href"] = href;
elementProps.listeners ??= [];
elementProps.listeners.push({
type: "click",
listener: (ev) => {
const href$1 = ev.target?.getAttribute("zotero-href");
href$1 && uiTool.getGlobal("Zotero").launchURL(href$1);
}
});
elementProps.classList ??= [];
elementProps.classList.push("zotero-text-link");
}
if (checkChildren) elementProps.children?.forEach((child) => replaceElement(child, uiTool));
}
const style = `
html {
color-scheme: light dark;
}
.zotero-text-link {
-moz-user-focus: normal;
color: -moz-nativehyperlinktext;
text-decoration: underline;
border: 1px solid transparent;
cursor: pointer;
}
.dropdown {
position: relative;
display: inline-block;
}
.dropdown-content {
display: none;
position: absolute;
background-color: var(--material-toolbar);
min-width: 160px;
box-shadow: 0px 0px 5px 0px rgba(0, 0, 0, 0.5);
border-radius: 5px;
padding: 5px 0 5px 0;
z-index: 999;
}
.dropdown-item {
margin: 0px;
padding: 5px 10px 5px 10px;
}
.dropdown-item:hover {
background-color: var(--fill-quinary);
}
`;
function fixMenuPopup(popup, uiTool) {
for (const item of popup.querySelectorAll("menuitem")) if (!item.innerHTML) uiTool.appendElement({
tag: "fragment",
children: [
{
tag: "image",
namespace: "xul",
classList: ["menu-icon"],
attributes: { "aria-hidden": "true" }
},
{
tag: "label",
namespace: "xul",
classList: ["menu-text"],
properties: { value: item.getAttribute("label") || "" },
attributes: {
crop: "end",
"aria-hidden": "true"
}
},
{
tag: "label",
namespace: "xul",
classList: ["menu-highlightable-text"],
properties: { textContent: item.getAttribute("label") || "" },
attributes: {
crop: "end",
"aria-hidden": "true"
}
},
{
tag: "label",
namespace: "xul",
classList: ["menu-accel"],
attributes: { "aria-hidden": "true" }
}
]
}, item);
}
//#endregion
//#region src/helpers/filePicker.ts
/**
* File picker helper.
* @param title window title
* @param mode
* @param filters Array<[hint string, filter string]>
* @param suggestion default file/folder
* @param window the parent window. By default it is the main window
* @param filterMask built-in filters
* @param directory directory in which to open the file picker
* @example
* ```ts
* await new FilePickerHelper(
* `${Zotero.getString("fileInterface.import")} MarkDown Document`,
* "open",
* [["MarkDown File(*.md)", "*.md"]]
* ).open();
* ```
*/
var FilePickerHelper = class extends BasicTool {
title;
mode;
filters;
suggestion;
directory;
window;
filterMask;
constructor(title, mode, filters, suggestion, window$1, filterMask, directory) {
super();
this.title = title;
this.mode = mode;
this.filters = filters;
this.suggestion = suggestion;
this.directory = directory;
this.window = window$1;
this.filterMask = filterMask;
}
async open() {
const Backend = ChromeUtils.importESModule("chrome://zotero/content/modules/filePicker.mjs").FilePicker;
const fp = new Backend();
fp.init(this.window || this.getGlobal("window"), this.title, this.getMode(fp));
for (const [label, ext] of this.filters || []) fp.appendFilter(label, ext);
if (this.filterMask) fp.appendFilters(this.getFilterMask(fp));
if (this.suggestion) fp.defaultString = this.suggestion;
if (this.directory) fp.displayDirectory = this.directory;
const userChoice = await fp.show();
switch (userChoice) {
case fp.returnOK:
case fp.returnReplace: return this.mode === "multiple" ? fp.files : fp.file;
default: return false;
}
}
getMode(fp) {
switch (this.mode) {
case "open": return fp.modeOpen;
case "save": return fp.modeSave;
case "folder": return fp.modeGetFolder;
case "multiple": return fp.modeOpenMultiple;
default: return 0;
}
}
getFilterMask(fp) {
switch (this.filterMask) {
case "all": return fp.filterAll;
case "html": return fp.filterHTML;
case "text": return fp.filterText;
case "images": return fp.filterImages;
case "xml": return fp.filterXML;
case "apps": return fp.filterApps;
case "urls": return fp.filterAllowURLs;
case "audio": return fp.filterAudio;
case "video": return fp.filterVideo;
default: return 1;
}
}
};
//#endregion
//#region src/helpers/guide.ts
/**
* Helper for creating a guide.
* Designed for creating a step-by-step guide for users.
* @alpha
*/
var GuideHelper = class extends BasicTool {
_steps = [];
constructor() {
super();
}
addStep(step) {
this._steps.push(step);
return this;
}
addSteps(steps) {
this._steps.push(...steps);
return this;
}
async show(doc) {
if (!doc?.ownerGlobal) throw new Error("Document is required.");
const guide = new Guide(doc.ownerGlobal);
await guide.show(this._steps);
const promise = new Promise((resolve) => {
guide._panel.addEventListener("guide-finished", () => resolve(guide));
});
await promise;
return guide;
}
async highlight(doc, step) {
if (!doc?.ownerGlobal) throw new Error("Document is required.");
const guide = new Guide(doc.ownerGlobal);
await guide.show([step]);
const promise = new Promise((resolve) => {
guide._panel.addEventListener("guide-finished", () => resolve(guide));
});
await promise;
return guide;
}
};
var Guide = class {
_window;
_id = `guide-${Zotero.Utilities.randomString()}`;
_panel;
_header;
_body;
_footer;
_progress;
_closeButton;
_prevButton;
_nextButton;
_steps;
_noClose;
_closed;
_autoNext;
_currentIndex;
initialized;
_cachedMasks = [];
get content() {
return this._window.MozXULElement.parseXULToFragment(`
<panel id="${this._id}" class="guide-panel" type="arrow" align="top" noautohide="true">
<html:div class="guide-panel-content">
<html:div class="guide-panel-header"></html:div>
<html:div class="guide-panel-body"></html:div>
<html:div class="guide-panel-footer">
<html:div class="guide-panel-progress"></html:div>
<html:div class="guide-panel-buttons">
<button id="prev-button" class="guide-panel-button" hidden="true"></button>
<button id="next-button" class="guide-panel-button" hidden="true"></button>
<button id="close-button" class="guide-panel-button" hidden="true"></button>
</html:div>
</html:div>
</html:div>
<html:style>
.guide-panel {
backgr