{"version":3,"file":"alchemy_admin.min.js","sources":["../../../javascript/alchemy_admin/i18n.js","../../../javascript/alchemy_admin/hotkeys.js","../../../javascript/alchemy_admin/utils/dom_helpers.js","../../../javascript/alchemy_admin/spinner.js","../../../javascript/alchemy_admin/dialog.js","../../../javascript/alchemy_admin/confirm_dialog.js","../../../javascript/alchemy_admin/please_wait_overlay.js","../../../javascript/alchemy_admin/dirty.js","../../../javascript/alchemy_admin/fixed_elements.js","../../../javascript/alchemy_admin/growler.js","../../../javascript/alchemy_admin/initializer.js","../../../javascript/alchemy_admin/link_dialog.js","../../../javascript/alchemy_admin/components/preview_window.js","../../../javascript/alchemy_admin/ingredient_anchor_link.js","../../../javascript/alchemy_admin/components/action.js","../../../javascript/alchemy_admin/components/remote_select.js","../../../javascript/alchemy_admin/components/attachment_select.js","../../../javascript/alchemy_admin/components/auto_submit.js","../../../javascript/alchemy_admin/components/button.js","../../../javascript/alchemy_admin/components/char_counter.js","../../../javascript/alchemy_admin/components/clipboard_button.js","../../../javascript/alchemy_admin/components/color_select.js","../../../javascript/alchemy_admin/components/datepicker.js","../../../javascript/alchemy_admin/components/dialog_link.js","../../../javascript/alchemy_admin/utils/ajax.js","../../../javascript/alchemy_admin/components/dom_id_select.js","../../../javascript/alchemy_admin/utils/dispatch_page_dirty_event.js","../../../javascript/alchemy_admin/components/element_editor/publish_element_button.js","../../../javascript/alchemy_admin/components/element_editor/delete_element_button.js","../../../javascript/alchemy_admin/components/element_editor.js","../../../javascript/alchemy_admin/components/element_select.js","../../../javascript/alchemy_admin/components/elements_window.js","../../../javascript/alchemy_admin/components/elements_window_handle.js","../../../javascript/alchemy_admin/components/file_editor.js","../../../javascript/alchemy_admin/components/list_filter.js","../../../javascript/alchemy_admin/components/message.js","../../../javascript/alchemy_admin/components/growl.js","../../../javascript/alchemy_admin/components/icon.js","../../../javascript/alchemy_admin/components/ingredient_group.js","../../../javascript/alchemy_admin/components/link_buttons/link_button.js","../../../javascript/alchemy_admin/components/link_buttons/unlink_button.js","../../../javascript/alchemy_admin/components/link_buttons.js","../../../javascript/alchemy_admin/utils/format.js","../../../javascript/alchemy_admin/components/node_select.js","../../../javascript/alchemy_admin/components/uploader/file_upload.js","../../../javascript/alchemy_admin/components/uploader/progress.js","../../../javascript/alchemy_admin/components/uploader.js","../../../javascript/alchemy_admin/components/overlay.js","../../../javascript/alchemy_admin/components/page_node.js","../../../javascript/alchemy_admin/components/page_publication_fields.js","../../../javascript/alchemy_admin/components/page_select.js","../../../javascript/alchemy_admin/components/picture_description_select.js","../../../javascript/alchemy_admin/components/picture_editor.js","../../../javascript/alchemy_admin/utils/debounce.js","../../../javascript/alchemy_admin/utils/max.js","../../../javascript/alchemy_admin/components/picture_thumbnail.js","../../../javascript/alchemy_admin/components/publish_page_button.js","../../../../node_modules/.pnpm/tom-select@2.6.1/node_modules/tom-select/dist/esm/contrib/microevent.js","../../../../node_modules/.pnpm/@orchidjs+unicode-variants@1.1.2/node_modules/@orchidjs/unicode-variants/dist/esm/regex.js","../../../../node_modules/.pnpm/@orchidjs+unicode-variants@1.1.2/node_modules/@orchidjs/unicode-variants/dist/esm/strings.js","../../../../node_modules/.pnpm/@orchidjs+unicode-variants@1.1.2/node_modules/@orchidjs/unicode-variants/dist/esm/index.js","../../../../node_modules/.pnpm/@orchidjs+sifter@1.1.0/node_modules/@orchidjs/sifter/dist/esm/utils.js","../../../../node_modules/.pnpm/@orchidjs+sifter@1.1.0/node_modules/@orchidjs/sifter/dist/esm/sifter.js","../../../../node_modules/.pnpm/tom-select@2.6.1/node_modules/tom-select/dist/esm/utils.js","../../../../node_modules/.pnpm/tom-select@2.6.1/node_modules/tom-select/dist/esm/vanilla.js","../../../../node_modules/.pnpm/tom-select@2.6.1/node_modules/tom-select/dist/esm/contrib/highlight.js","../../../../node_modules/.pnpm/tom-select@2.6.1/node_modules/tom-select/dist/esm/constants.js","../../../../node_modules/.pnpm/tom-select@2.6.1/node_modules/tom-select/dist/esm/defaults.js","../../../../node_modules/.pnpm/tom-select@2.6.1/node_modules/tom-select/dist/esm/getSettings.js","../../../../node_modules/.pnpm/tom-select@2.6.1/node_modules/tom-select/dist/esm/tom-select.js","../../../../node_modules/.pnpm/tom-select@2.6.1/node_modules/tom-select/dist/esm/contrib/microplugin.js","../../../../node_modules/.pnpm/tom-select@2.6.1/node_modules/tom-select/dist/esm/plugins/checkbox_options/plugin.js","../../../../node_modules/.pnpm/tom-select@2.6.1/node_modules/tom-select/dist/esm/plugins/clear_button/plugin.js","../../../../node_modules/.pnpm/tom-select@2.6.1/node_modules/tom-select/dist/esm/plugins/drag_drop/plugin.js","../../../../node_modules/.pnpm/tom-select@2.6.1/node_modules/tom-select/dist/esm/plugins/dropdown_header/plugin.js","../../../../node_modules/.pnpm/tom-select@2.6.1/node_modules/tom-select/dist/esm/plugins/caret_position/plugin.js","../../../../node_modules/.pnpm/tom-select@2.6.1/node_modules/tom-select/dist/esm/plugins/dropdown_input/plugin.js","../../../../node_modules/.pnpm/tom-select@2.6.1/node_modules/tom-select/dist/esm/plugins/input_autogrow/plugin.js","../../../../node_modules/.pnpm/tom-select@2.6.1/node_modules/tom-select/dist/esm/plugins/remove_button/plugin.js","../../../../node_modules/.pnpm/tom-select@2.6.1/node_modules/tom-select/dist/esm/plugins/virtual_scroll/plugin.js","../../../../node_modules/.pnpm/tom-select@2.6.1/node_modules/tom-select/dist/esm/tom-select.complete.js","../../../../node_modules/.pnpm/tom-select@2.6.1/node_modules/tom-select/dist/esm/plugins/change_listener/plugin.js","../../../../node_modules/.pnpm/tom-select@2.6.1/node_modules/tom-select/dist/esm/plugins/no_backspace_delete/plugin.js","../../../../node_modules/.pnpm/tom-select@2.6.1/node_modules/tom-select/dist/esm/plugins/no_active_items/plugin.js","../../../../node_modules/.pnpm/tom-select@2.6.1/node_modules/tom-select/dist/esm/plugins/optgroup_columns/plugin.js","../../../../node_modules/.pnpm/tom-select@2.6.1/node_modules/tom-select/dist/esm/plugins/restore_on_backspace/plugin.js","../../../../node_modules/.pnpm/@floating-ui+utils@0.2.11/node_modules/@floating-ui/utils/dist/floating-ui.utils.mjs","../../../../node_modules/.pnpm/@floating-ui+core@1.7.5/node_modules/@floating-ui/core/dist/floating-ui.core.mjs","../../../../node_modules/.pnpm/@floating-ui+utils@0.2.11/node_modules/@floating-ui/utils/dist/floating-ui.utils.dom.mjs","../../../../node_modules/.pnpm/@floating-ui+dom@1.7.6/node_modules/@floating-ui/dom/dist/floating-ui.dom.mjs","../../../javascript/alchemy_admin/components/select.js","../../../javascript/alchemy_admin/components/sitemap.js","../../../javascript/alchemy_admin/components/sortable_elements.js","../../../javascript/alchemy_admin/components/spinner.js","../../../javascript/alchemy_admin/components/tags_autocomplete.js","../../../javascript/alchemy_admin/components/tinymce.js","../../../javascript/alchemy_admin/components/update_check.js","../../../javascript/alchemy_admin/templates/compiled.js","../../../javascript/alchemy_admin/shoelace_theme.js","../../../javascript/alchemy_admin/utils/events.js","../../../javascript/alchemy_admin/image_cropper.js","../../../javascript/alchemy_admin/image_overlay.js","../../../javascript/alchemy_admin/picture_selector.js","../../../javascript/alchemy_admin/node_tree.js","../../../javascript/alchemy_admin.js"],"sourcesContent":["const KEY_SEPARATOR = /\\./\n\nfunction nestedTranslation(translations, key) {\n const keys = key.split(KEY_SEPARATOR)\n const group = translations[keys[0]]\n if (group) {\n return group[keys[1]] || key\n }\n return key\n}\n\nfunction getTranslation(key) {\n const locale = currentLocale()\n const translations = Alchemy.translations\n\n if (!translations) {\n console.warn(`Translations for locale ${locale} not found!`)\n return key\n }\n\n if (KEY_SEPARATOR.test(key)) {\n return nestedTranslation(translations, key)\n }\n return translations[key] || key\n}\n\nexport function currentLocale() {\n if (document.documentElement.lang) {\n return document.documentElement.lang\n }\n return \"en\"\n}\n\nexport function translate(key, replacement = undefined) {\n let translation = getTranslation(key)\n\n if (replacement) {\n return translation.replace(/%\\{.+\\}/, replacement)\n }\n return translation\n}\n\nexport async function setupSelectLocale() {\n const locale = currentLocale()\n if (locale === \"en\") return\n\n await import(`select2/${locale}.js`)\n $.extend($.fn.select2.defaults, $.fn.select2.locales[locale])\n}\n","import \"keymaster\"\n\nconst bindedHotkeys = []\n\nexport default function (scope = document) {\n // The scope can be a jQuery object because we still use jQuery in alchemy_admin/dialog.js.\n if (scope instanceof jQuery) {\n scope = scope[0]\n }\n\n // Unbind all previously registered hotkeys if we are not inside a dialog.\n if (scope === document) {\n bindedHotkeys.forEach((hotkey) => key.unbind(hotkey))\n }\n\n // Binds keyboard shortcuts to search fields.\n const search_fields = scope.querySelectorAll(\".search_input_field\")\n const search_fields_clear = scope.querySelectorAll(\n \".search_field_clear, .js_filter_field_clear\"\n )\n key(\"alt+f\", function () {\n key.setScope(\"search\")\n search_fields.forEach((el) => el.focus({ focusVisible: true }))\n return false\n })\n bindedHotkeys.push(\"alt+f\")\n key(\"esc\", \"search\", function () {\n search_fields_clear.forEach((el) => el.click())\n search_fields.forEach((el) => el.blur())\n })\n bindedHotkeys.push(\"esc\")\n\n // Binds click events to buttons with hotkeys.\n //\n // Simply add a data-alchemy-hotkey attribute to your link.\n // If a hotkey is triggered by user, the click event of the element gets triggerd.\n //\n scope.querySelectorAll(\"[data-alchemy-hotkey]\").forEach(function (el) {\n const hotkey = el.dataset.alchemyHotkey\n key(hotkey, () => el.click())\n bindedHotkeys.push(hotkey)\n })\n}\n","/**\n * create a HTML element\n * @param {string} text\n * @returns {HTMLElement}\n */\nexport function createHtmlElement(text) {\n const element = document.createElement(\"template\")\n element.innerHTML = text\n return element.content.children[0]\n}\n\n/**\n * wrap element with wrappingElement\n * @param {HTMLElement} element\n * @param {HTMLElement} wrappingElement\n */\nexport function wrap(element, wrappingElement) {\n element.replaceWith(wrappingElement)\n wrappingElement.appendChild(element)\n}\n","import { createHtmlElement } from \"alchemy_admin/utils/dom_helpers\"\n\nexport default class Spinner {\n constructor(size, color = \"currentColor\") {\n this.size = size\n this.color = color\n this.spinner = undefined\n }\n\n /**\n * @returns {HTMLElement|undefined}\n */\n get el() {\n return this.spinner\n }\n /**\n * @param {HTMLElement|undefined} parent\n */\n spin(parent) {\n if (typeof parent === \"undefined\") {\n parent = document.body\n }\n this.spinner = createHtmlElement(\n ``\n )\n parent.append(this.spinner)\n return this\n }\n\n stop() {\n if (this.spinner) {\n this.spinner.remove()\n this.spinner = undefined\n }\n }\n}\n","import Hotkeys from \"alchemy_admin/hotkeys\"\nimport Spinner from \"alchemy_admin/spinner\"\n\n// Collection of all current dialog instances\nconst currentDialogs = []\n\nconst DEFAULTS = {\n header_height: 36,\n size: \"400x300\",\n padding: true,\n title: \"\",\n modal: true,\n overflow: \"visible\",\n ready: () => {},\n closed: () => {}\n}\n\nexport class Dialog {\n // Arguments:\n // - url: The url to load the content from via ajax\n // - options: A object holding options\n // - size: The maximum size of the Dialog\n // - title: The title of the Dialog\n constructor(url, options = {}) {\n this.url = url\n this.options = { ...DEFAULTS, ...options }\n this.$document = $(document)\n this.$window = $(window)\n this.$body = $(\"body\")\n const size = this.options.size.split(\"x\")\n this.width = parseInt(size[0], 10)\n this.height = parseInt(size[1], 10)\n this.build()\n this.resize()\n }\n\n // Opens the Dialog and loads the content via ajax.\n open() {\n this.dialog.trigger(\"Alchemy.DialogOpen\")\n this.bind_close_events()\n window.requestAnimationFrame(() => {\n this.dialog_container.addClass(\"open\")\n if (this.overlay != null) {\n return this.overlay.addClass(\"open\")\n }\n })\n this.$body.addClass(\"prevent-scrolling\")\n currentDialogs.push(this)\n this.load()\n }\n\n // Closes the Dialog and removes it from the DOM\n close() {\n this.dialog.trigger(\"DialogClose.Alchemy\")\n this.$document.off(\"keydown\")\n this.dialog_container.removeClass(\"open\")\n if (this.overlay != null) {\n this.overlay.removeClass(\"open\")\n }\n this.$document.on(\n \"webkitTransitionEnd transitionend oTransitionEnd\",\n () => {\n this.$document.off(\"webkitTransitionEnd transitionend oTransitionEnd\")\n this.dialog_container.remove()\n if (this.overlay != null) {\n this.overlay.remove()\n }\n this.$body.removeClass(\"prevent-scrolling\")\n currentDialogs.pop(this)\n if (this.options.closed != null) {\n return this.options.closed()\n }\n }\n )\n return true\n }\n\n // Loads the content via ajax and replaces the Dialog body with server response.\n load() {\n this.show_spinner()\n $.get(this.url, (data) => {\n this.replace(data)\n }).fail((xhr) => {\n this.show_error(xhr)\n })\n }\n\n // Reloads the Dialog content\n reload() {\n this.dialog_body.empty()\n this.load()\n }\n\n // Replaces the dialog body with given content and initializes it.\n replace(data) {\n this.remove_spinner()\n this.dialog_body.hide()\n this.dialog_body.html(data)\n this.init()\n this.dialog[0].dispatchEvent(\n new CustomEvent(\"DialogReady.Alchemy\", {\n bubbles: true,\n detail: {\n body: this.dialog_body[0]\n }\n })\n )\n if (this.options.ready != null) {\n this.options.ready(this.dialog_body)\n }\n this.dialog_body.show()\n }\n\n // Adds a spinner into Dialog body\n show_spinner() {\n this.spinner = new Spinner(\"medium\")\n this.spinner.spin(this.dialog_body[0])\n }\n\n // Removes the spinner from Dialog body\n remove_spinner() {\n this.spinner.stop()\n }\n\n // Initializes the Dialog body\n init() {\n Hotkeys(this.dialog_body)\n this.watch_remote_forms()\n window.requestAnimationFrame(() => {\n this.dialog_body.find(\"[autofocus]\").focus()\n })\n }\n\n // Watches ajax requests inside of dialog body and replaces the content accordingly\n watch_remote_forms() {\n const $form = $('[data-remote=\"true\"]', this.dialog_body)\n\n $form.on(\"ajax:success\", (event) => {\n const xhr = event.detail[2]\n const content_type = xhr.getResponseHeader(\"Content-Type\")\n if (content_type.match(/javascript/)) {\n return\n } else {\n this.dialog_body.html(xhr.responseText)\n this.init()\n }\n })\n\n $form.on(\"ajax:error\", (event) => {\n const statusText = event.detail[1]\n const xhr = event.detail[2]\n this.show_error(xhr, statusText)\n })\n }\n\n // Displays an error message\n show_error(xhr, statusText) {\n if (xhr.status === 422) {\n this.dialog_body.html(xhr.responseText)\n this.init()\n return\n }\n\n const { error_body, error_header, error_type } = this.error_messages(\n xhr,\n statusText\n )\n\n const $errorDiv = $(`\n

${error_header}

\n

${error_body}

\n
`)\n\n this.dialog_body.html($errorDiv)\n }\n\n // Returns error message based on xhr status\n error_messages(xhr, statusText) {\n let error_body,\n error_header,\n error_type = \"warning\"\n\n switch (xhr.status) {\n case 0:\n error_header = \"The server does not respond.\"\n error_body = \"Please check server and try again.\"\n break\n case 403:\n error_header = \"You are not authorized!\"\n error_body = \"Please close this window.\"\n break\n default:\n error_type = \"error\"\n if (statusText) {\n error_header = statusText\n console.error(xhr.responseText)\n } else {\n error_header = `${xhr.statusText} (${xhr.status})`\n }\n error_body = \"Please check log and try again.\"\n }\n\n return { error_header, error_body, error_type }\n }\n\n // Binds close events on:\n // - Close button\n // - Overlay (if the Dialog is a modal)\n // - ESC Key\n bind_close_events() {\n this.close_button.on(\"click\", () => {\n this.close()\n })\n this.dialog_container.addClass(\"closable\").on(\"click\", (e) => {\n if (e.target !== this.dialog_container.get(0)) {\n return true\n }\n this.close()\n return false\n })\n this.$document.keydown((e) => {\n if (e.which === 27) {\n this.close()\n return false\n } else {\n return true\n }\n })\n }\n\n // Builds the html structure of the Dialog\n build() {\n this.dialog_container = $('
')\n this.dialog = $('
')\n this.dialog_body = $('
')\n this.dialog_header = $('
')\n this.dialog_title = $('
')\n this.close_button = $(\n ''\n )\n this.dialog_title.text(this.options.title)\n this.dialog_header.append(this.dialog_title)\n this.dialog_header.append(this.close_button)\n this.dialog.append(this.dialog_header)\n this.dialog.append(this.dialog_body)\n this.dialog_container.append(this.dialog)\n if (this.options.modal) {\n this.dialog.addClass(\"modal\")\n }\n if (this.options.padding) {\n this.dialog_body.addClass(\"padded\")\n }\n if (this.options.modal) {\n this.overlay = $('
')\n this.$body.append(this.overlay)\n }\n this.$body.append(this.dialog_container)\n }\n\n // Sets the correct size of the dialog\n // It normalizes the given size, so that it never acceeds the window size.\n resize() {\n const { width, height } = this.getSize()\n\n this.dialog.css({\n width: width,\n \"min-height\": height,\n overflow: this.options.overflow\n })\n\n if (this.options.overflow === \"hidden\") {\n this.dialog_body.css({\n height: height,\n overflow: \"auto\"\n })\n } else {\n this.dialog_body.css({\n \"min-height\": height,\n overflow: \"visible\"\n })\n }\n }\n\n getSize() {\n const padding = this.options.padding ? 16 : 0\n const doc_width = this.$window.width()\n const doc_height = this.$window.height()\n\n let width = this.width\n let height = this.height\n\n if (width >= doc_width) {\n width = doc_width - padding\n }\n\n if (height >= doc_height) {\n height = doc_height - padding - DEFAULTS.header_height\n }\n\n return { width, height }\n }\n}\n\n// Gets the last dialog instantiated, which is the current one.\nexport function currentDialog() {\n const { length } = currentDialogs\n if (length === 0) {\n return\n }\n return currentDialogs[length - 1]\n}\n\n// Utility function to close the current Dialog\n//\n// You can pass a callback function, that gets triggered after the Dialog gets closed.\n//\nexport function closeCurrentDialog(callback) {\n const dialog = currentDialog()\n if (dialog != null) {\n dialog.options.closed = callback\n return dialog.close()\n }\n}\n\n// Utility function to open a new Dialog\nexport function openDialog(url, options) {\n if (!url) {\n throw \"No url given! Please provide an url.\"\n }\n const dialog = new Dialog(url, options)\n dialog.open()\n}\n","import { createHtmlElement } from \"alchemy_admin/utils/dom_helpers\"\nimport { translate } from \"alchemy_admin/i18n\"\n\nconst getDefaults = () => ({\n // The default size of the dialog\n size: \"300x100\",\n title: translate(\"Please confirm\"),\n ok_label: translate(\"Yes\"),\n cancel_label: translate(\"No\"),\n on_ok() {}\n})\n\nclass ConfirmDialog {\n constructor(message, options = {}) {\n this.message = message\n this.options = { ...getDefaults(), ...options }\n this.#build()\n this.#bindEvents()\n }\n\n open() {\n requestAnimationFrame(() => {\n this.dialog.show()\n })\n }\n\n #build() {\n const width = this.options.size.split(\"x\")[0]\n this.dialog = createHtmlElement(`\n \n ${this.message}\n \n \n \n `)\n document.body.append(this.dialog)\n }\n\n #bindEvents() {\n this.cancelButton.addEventListener(\"click\", (evt) => {\n evt.preventDefault()\n this.options.on_cancel()\n this.dialog.hide()\n })\n this.okButton.addEventListener(\"click\", (evt) => {\n evt.preventDefault()\n this.options.on_ok()\n this.dialog.hide()\n })\n // Prevent the dialog from closing when the user clicks on the overlay\n this.dialog.addEventListener(\"sl-request-close\", (event) => {\n if (event.detail.source === \"overlay\") {\n this.options.on_cancel()\n event.preventDefault()\n }\n })\n // Remove the dialog from the DOM after it has been hidden\n this.dialog.addEventListener(\"sl-after-hide\", () => {\n this.dialog.remove()\n })\n }\n\n get cancelButton() {\n return this.dialog.querySelector(\"button[type=reset]\")\n }\n\n get okButton() {\n return this.dialog.querySelector(\"button[type=submit]\")\n }\n}\n\n/* Opens a confirm dialog\n *\n * @param {string} message - The message that will be displayed to the user\n * @param {Object} [options={}] - Configuration options for the dialog\n * @param {string} [options.title=\"Please confirm\"] - The title of the overlay window\n * @param {string} [options.cancel_label=\"No\"] - The label of the cancel button\n * @param {string} [options.ok_label=\"Yes\"] - The label of the ok button\n *\n * @returns {Promise} A promise that resolves to true when the OK button is clicked and\n * resolves to false when the cancel button is clicked. Works as confirm dialog replacement\n * for Turbo.confirm.\n */\nexport function openConfirmDialog(message, options = {}) {\n return new Promise((resolve) => {\n const dialog = new ConfirmDialog(message, {\n ...options,\n on_ok() {\n resolve(true)\n },\n on_cancel() {\n resolve(false)\n }\n })\n dialog.open()\n })\n}\n","/**\n * To show the \"Please wait\" overlay.\n * Pass false to hide it.\n * @param {boolean} show\n */\nexport default function pleaseWaitOverlay(show = true) {\n document.querySelector(\"alchemy-overlay\").show = !!show\n}\n","import { openConfirmDialog } from \"alchemy_admin/confirm_dialog\"\nimport { translate } from \"alchemy_admin/i18n\"\nimport pleaseWaitOverlay from \"alchemy_admin/please_wait_overlay\"\n\nfunction checkPageDirtyness(element) {\n let callback = () => {}\n\n if ($(element).is(\"form\")) {\n callback = function () {\n const $form = $(\n `
`\n )\n $form.append($(element).find(\"input\"))\n $form.appendTo(\"body\")\n\n pleaseWaitOverlay()\n $form.trigger(\"submit\")\n }\n } else if ($(element).is(\"a\")) {\n callback = () => Turbo.visit(element.pathname)\n }\n\n const isPageDirty =\n document.querySelectorAll(\"alchemy-element-editor.dirty\").length > 0\n\n if (isPageDirty) {\n openConfirmDialog(translate(\"page_dirty_notice\"), {\n title: translate(\"warning\"),\n ok_label: translate(\"ok\"),\n cancel_label: translate(\"cancel\")\n }).then((proceed) => {\n if (proceed) {\n window.onbeforeunload = void 0\n callback()\n }\n })\n return false\n }\n return true\n}\n\nfunction PageLeaveObserver() {\n document.querySelectorAll(\"#main_navi a\").forEach((element) => {\n element.addEventListener(\"click\", (event) => {\n if (!checkPageDirtyness(event.currentTarget)) {\n event.preventDefault()\n }\n })\n })\n}\n\nexport default {\n checkPageDirtyness,\n PageLeaveObserver\n}\n","// Creates a fixed element tab.\nexport function createTab(element_id, label) {\n const fixed_elements = document.getElementById(\"fixed-elements\")\n const panel_name = `fixed-element-${element_id}`\n\n const tab = `${label}`\n const panel = ``\n\n fixed_elements.innerHTML += tab + panel\n\n window.requestAnimationFrame(function () {\n fixed_elements.show(panel_name)\n })\n}\n\nexport function removeTab(element_id) {\n const fixed_elements = document.getElementById(\"fixed-elements\")\n const panel_name = `fixed-element-${element_id}`\n\n fixed_elements.querySelector(`sl-tab[panel=\"${panel_name}\"]`).remove()\n fixed_elements.querySelector(`sl-tab-panel[name=\"${panel_name}\"]`).remove()\n\n fixed_elements.show(\"main-content-elements\")\n}\n","import { createHtmlElement } from \"alchemy_admin/utils/dom_helpers\"\n\nfunction build(message, flashType) {\n const flashNotices = document.getElementById(\"flash_notices\")\n const flashMessage = createHtmlElement(`\n \n ${message}\n \n `)\n flashNotices.append(flashMessage)\n}\n\nexport function growl(message, style = \"notice\") {\n build(message, style)\n}\n","import Hotkeys from \"alchemy_admin/hotkeys\"\nimport pleaseWaitOverlay from \"alchemy_admin/please_wait_overlay\"\nimport { openDialog } from \"alchemy_admin/dialog\"\n\n// Opens the help dialog when the user presses the \"?\" key outside of a field.\nfunction showHelp(evt) {\n if (\n !$(evt.target).is(\"input, textarea\") &&\n String.fromCharCode(evt.which) === \"?\"\n ) {\n openDialog(\"/admin/help\", {\n title: Alchemy.t(\"help\"),\n size: \"400x492\"\n })\n return false\n } else {\n return true\n }\n}\n\nexport default function Initializer() {\n // We obviously have javascript enabled.\n document.documentElement.classList.remove(\"no-js\")\n\n // Initialize hotkeys.\n Hotkeys()\n\n // (Re)bind the help dialog hotkey.\n document.removeEventListener(\"keypress\", showHelp)\n document.addEventListener(\"keypress\", showHelp)\n\n // Add observer for please wait overlay.\n document.querySelectorAll(\".please_wait\").forEach((element) => {\n element.addEventListener(\"click\", pleaseWaitOverlay)\n })\n\n // Hack for enabling tab focus for 's styled as button.\n document.querySelectorAll(\"a.button\").forEach((button) => {\n button.setAttribute(\"tabindex\", 0)\n })\n\n // Override the filter of keymaster.js so we can blur the fields on esc key.\n key.filter = function (event) {\n let tagName = (event.target || event.srcElement).tagName\n return (\n key.isPressed(\"esc\") ||\n !(tagName === \"INPUT\" || tagName === \"SELECT\" || tagName === \"TEXTAREA\")\n )\n }\n}\n","import { translate } from \"alchemy_admin/i18n\"\nimport { Dialog } from \"alchemy_admin/dialog\"\n\n// Matches a URL fragment (#anchor) at the end of a string.\n// Covers RFC 3986 unreserved characters (ALPHA, DIGIT, \"-\", \".\", \"_\", \"~\")\n// which are the characters valid in URL fragments and common in DOM element IDs.\nconst ANCHOR_REGEX = /#[\\w.~-]+$/\n\n// Represents the link Dialog that appears, if a user clicks the link buttons\n// in TinyMCE or on an Ingredient that has links enabled (e.g. Picture)\n//\nexport class LinkDialog extends Dialog {\n #onCreateLink\n\n constructor(link) {\n const url = new URL(Alchemy.routes.link_admin_pages_path, window.location)\n const parameterMapping = {\n url: link.url,\n selected_tab: link.type,\n link_title: link.title,\n link_target: link.target\n }\n\n // searchParams.set would also add undefined values\n Object.keys(parameterMapping).forEach((key) => {\n if (parameterMapping[key]) {\n url.searchParams.set(key, parameterMapping[key])\n }\n })\n\n super(url.href, {\n size: \"600x320\",\n title: translate(\"Link\")\n })\n }\n\n /**\n * Called from Dialog class after the url was loaded\n */\n replace(data) {\n // let Dialog class handle the content replacement\n super.replace(data)\n this.#attachEvents()\n }\n\n /**\n * make the open method a promise\n * maybe in a future version the whole Dialog will respond with a promise result if the dialog is closing\n * @returns {Promise}\n */\n open() {\n super.open()\n return new Promise((resolve) => (this.#onCreateLink = resolve))\n }\n\n /**\n * Attaches click events to forms in the link dialog.\n */\n #attachEvents() {\n // enable the dom selection in internal link tab\n const internalForm = document.querySelector(\n '[data-link-form-type=\"internal\"]'\n )\n const attachmentSelect = document.querySelector(\n '[data-link-form-type=\"file\"] alchemy-attachment-select'\n )\n\n internalForm.addEventListener(\"Alchemy.RemoteSelect.Change\", (e) => {\n this.#updatePage(e.detail.added)\n })\n\n attachmentSelect.addEventListener(\"Alchemy.RemoteSelect.Change\", (e) => {\n const attachment = e.detail.added\n document.getElementById(\"file_link\").value = attachment\n ? attachment.url\n : \"\"\n })\n\n document.querySelectorAll(\"[data-link-form-type]\").forEach((form) => {\n form.addEventListener(\"submit\", (e) => {\n e.preventDefault()\n this.#submitForm(e.target.dataset.linkFormType)\n })\n })\n }\n\n /**\n * update page select and set anchor select\n * @param page\n */\n #updatePage(page = null) {\n const internalLink = document.getElementById(\"internal_link\")\n const domIdSelect = document.querySelector(\n '[data-link-form-type=\"internal\"] alchemy-dom-id-api-select'\n )\n\n internalLink.value = page ? page.url_path : \"\"\n domIdSelect.page = page ? page.id : undefined\n }\n\n /**\n * submit the form itself\n * @param linkType\n */\n #submitForm(linkType) {\n const elementAnchor = document.getElementById(\"element_anchor\")\n let url = document.getElementById(`${linkType}_link`).value\n\n if (linkType === \"internal\" && elementAnchor.value !== \"\") {\n // remove possible fragments on the url and attach the fragment (which contains the #)\n url = url.replace(ANCHOR_REGEX, \"\") + elementAnchor.value\n } else if (linkType === \"external\" && !url.match(Alchemy.link_url_regexp)) {\n // show validation error and prevent link creation\n this.#showValidationError()\n return\n }\n\n // Create the link\n this.#onCreateLink({\n url: url.trim(),\n title: document.getElementById(`${linkType}_link_title`).value,\n target: document.getElementById(`${linkType}_link_target`)?.value,\n type: linkType\n })\n this.close()\n }\n\n /**\n * Shows validation errors\n */\n #showValidationError() {\n const errors = document.getElementById(\"errors\")\n errors.querySelector(\"ul\").innerHTML =\n `
  • ${Alchemy.t(\"url_validation_failed\")}
  • `\n errors.style.display = \"block\"\n }\n}\n","import { growl } from \"alchemy_admin/growler\"\nimport { translate } from \"alchemy_admin/i18n\"\n\nclass PreviewWindow extends HTMLIFrameElement {\n #afterLoad\n #reloadIcon\n #loadTimeout\n\n handleEvent(evt) {\n if (evt.type === \"load\") {\n this.#clearLoadTimeout()\n this.#stopSpinner()\n this.#afterLoad?.call(this, evt)\n }\n }\n\n #onPreviewReady = (event) => {\n if (event.data.message === \"Alchemy.previewReady\") {\n this.#clearLoadTimeout()\n this.#stopSpinner()\n this.#afterLoad?.call(this, event)\n }\n }\n\n connectedCallback() {\n let url = this.url\n\n this.addEventListener(\"load\", this)\n this.#attachEvents()\n window.addEventListener(\"message\", this.#onPreviewReady)\n\n if (window.localStorage.getItem(\"alchemy-preview-url\")) {\n url = window.localStorage.getItem(\"alchemy-preview-url\")\n this.previewUrlSelect.value = url\n }\n\n this.refresh(url)\n }\n\n disconnectedCallback() {\n this.removeEventListener(\"load\", this)\n key.unbind(\"alt+r\")\n window.removeEventListener(\"message\", this.#onPreviewReady)\n }\n\n postMessage(data) {\n this.contentWindow.postMessage(data, \"*\")\n }\n\n resize(width) {\n this.style.width = `${width}px`\n }\n\n refresh(url) {\n this.#startSpinner()\n\n if (url) {\n this.src = url\n } else {\n this.src = this.url\n }\n\n // Set 5s timeout as fallback - if iframe doesn't load, stop spinner anyway\n this.#clearLoadTimeout()\n this.#loadTimeout = setTimeout(() => {\n this.#stopSpinner()\n growl(translate(\"Preview failed to load\"), \"warning\")\n }, 5000)\n\n return new Promise((resolve) => {\n this.#afterLoad = resolve\n })\n }\n\n set isDragged(dragged) {\n this.style.transitionProperty = dragged ? \"none\" : null\n this.style.pointerEvents = dragged ? \"none\" : null\n }\n\n #attachEvents() {\n this.reloadButton?.addEventListener(\"click\", (evt) => {\n evt.preventDefault()\n this.refresh()\n })\n\n key(\"alt+r\", () => this.refresh())\n\n this.sizeSelect.addEventListener(\"change\", (evt) => {\n const select = evt.target\n const width = select.value\n\n if (width === \"\") {\n this.style.width = null\n } else {\n this.resize(width)\n }\n })\n\n this.previewUrlSelect?.addEventListener(\"change\", (evt) => {\n const url = evt.target.value\n window.localStorage.setItem(\"alchemy-preview-url\", url)\n this.refresh(url)\n })\n }\n\n #startSpinner() {\n // Only save the reload icon if we're not already showing a spinner\n if (!this.reloadButton.innerHTML.includes(\"alchemy-spinner\")) {\n this.#reloadIcon = this.reloadButton.innerHTML\n }\n this.reloadButton.innerHTML = ``\n }\n\n #stopSpinner() {\n this.reloadButton.innerHTML = this.#reloadIcon\n }\n\n #clearLoadTimeout() {\n if (this.#loadTimeout) {\n clearTimeout(this.#loadTimeout)\n this.#loadTimeout = null\n }\n }\n\n get url() {\n return this.getAttribute(\"url\")\n }\n\n get sizeSelect() {\n return document.querySelector(\"select#preview_size\")\n }\n\n get previewUrlSelect() {\n return document.querySelector(\"select#preview_url\")\n }\n\n get reloadButton() {\n return document.querySelector(\"#reload_preview_button\")\n }\n}\n\ncustomElements.define(\"alchemy-preview-window\", PreviewWindow, {\n extends: \"iframe\"\n})\n\nexport function reloadPreview() {\n const previewWindow = document.getElementById(\"alchemy_preview_window\")\n previewWindow.refresh()\n}\n","export default class IngredientAnchorLink {\n static updateIcon(ingredientId, active = false) {\n const ingredientEditor = document.querySelector(\n `[data-ingredient-id=\"${ingredientId}\"]`\n )\n if (ingredientEditor) {\n const icon = ingredientEditor.querySelector(\n \".edit-ingredient-anchor-link alchemy-icon\"\n )\n icon.setAttribute(\"icon-style\", active ? \"fill\" : \"line\")\n }\n }\n}\n","import { reloadPreview } from \"alchemy_admin/components/preview_window\"\nimport { removeTab } from \"alchemy_admin/fixed_elements\"\nimport { closeCurrentDialog } from \"alchemy_admin/dialog\"\nimport IngredientAnchorLink from \"alchemy_admin/ingredient_anchor_link\"\nimport pleaseWaitOverlay from \"alchemy_admin/please_wait_overlay\"\n\nclass Action extends HTMLElement {\n constructor() {\n super()\n\n // map action names with Javascript functions\n this.actions = {\n // add a intermediate closeCurrentDialog - action\n // this will be gone, if all dialogs are working with a promise and\n // we don't have to implicitly close the dialog\n closeCurrentDialog,\n reloadPreview,\n removeFixedElement: removeTab,\n updateAnchorIcon: IngredientAnchorLink.updateIcon,\n hidePleaseWaitOverlay() {\n pleaseWaitOverlay(false)\n }\n }\n }\n\n connectedCallback() {\n const func = this.actions[this.name]\n\n if (func) {\n func(...this.params)\n } else {\n console.error(`Unknown Alchemy action: ${this.name}`)\n }\n\n this.remove()\n }\n\n get name() {\n return this.getAttribute(\"name\")\n }\n\n get params() {\n if (this.hasAttribute(\"params\")) {\n return JSON.parse(this.getAttribute(\"params\"))\n }\n return []\n }\n}\n\ncustomElements.define(\"alchemy-action\", Action)\n","import { setupSelectLocale } from \"alchemy_admin/i18n\"\n\n/**\n * Escapes a string for use within HTML. Select2 hands the return value of\n * `formatResult`/`formatSelection` straight to jQuery, so everything woven into\n * those templates has to be escaped here.\n * @param {string} value\n * @returns {string}\n */\nexport function escapeHtml(value) {\n return `${value}`\n .replace(/&/g, \"&\")\n .replace(//g, \">\")\n .replace(/\"/g, \""\")\n}\n\n/**\n * Escapes a string for literal use inside a `RegExp`, so a term with\n * metacharacters (e.g. `(`) matches itself instead of being compiled as a\n * pattern, which could match the wrong text or throw a SyntaxError.\n * @param {string} value\n * @returns {string}\n */\nexport function escapeRegExp(value) {\n return String(value).replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\")\n}\n\nexport function hightlightTerm(name, term) {\n // The result is rendered raw as the entry's name, so the name must be escaped\n // before the term markup is woven in — otherwise a crafted name (e.g. a menu\n // node title) is a stored XSS sink.\n const escapedName = escapeHtml(name)\n if (!term) return escapedName\n return escapedName.replace(\n new RegExp(escapeRegExp(term), \"gi\"),\n (match) => `${match}`\n )\n}\n\nexport class RemoteSelect extends HTMLElement {\n #select2 = null\n\n async connectedCallback() {\n await setupSelectLocale()\n // Bail out if the element was disconnected while the locale was loading.\n // Otherwise Select2 would leak onto a detached input.\n if (!this.isConnected) return\n\n this.input.classList.add(\"alchemy_selectbox\")\n\n this.#select2 = $(this.input)\n .select2(this.select2Config)\n .on(\"select2-open\", this.#onOpen)\n .on(\"change\", this.#onChange)\n }\n\n disconnectedCallback() {\n if (this.#select2) {\n this.#select2.off(\"select2-open\", this.#onOpen)\n this.#select2.off(\"change\", this.#onChange)\n this.#select2.select2(\"destroy\")\n this.#select2 = null\n }\n }\n\n #onOpen = (evt) => this.onOpen(evt)\n #onChange = (evt) => this.onChange(evt)\n\n /**\n * Optional on change handler called by Select2.\n * @param {Event} event\n */\n onChange(event) {\n // Update selection attribute so re-attaching the select2 component to\n // the same input (e.g. after dragndrop) does not reset the selection.\n if (event.added) {\n this.setAttribute(\"selection\", JSON.stringify(event.added))\n }\n this.dispatchCustomEvent(\"RemoteSelect.Change\", {\n removed: event.removed,\n added: event.added\n })\n }\n\n /**\n * Optional on open handler called by Select2.\n * @param {Event} event\n */\n onOpen(event) {\n // add focus to the search input. Select2 is handling the focus on the first opening,\n // but it does not work the second time. One process in select2 is \"stealing\" the focus\n // if the command is not delayed. It is an intermediate solution until we are going to\n // move away from Select2\n setTimeout(() => {\n document.querySelector(\"#select2-drop .select2-input\").focus()\n }, 100)\n }\n\n /**\n * Dispatches a custom event with given name, namespaced under `Alchemy.`.\n * Subclasses may call this to emit their own events.\n * @param {string} name The name of the custom event\n * @param {object} detail Optional event details\n */\n dispatchCustomEvent(name, detail = {}) {\n this.dispatchEvent(\n new CustomEvent(`Alchemy.${name}`, { bubbles: true, detail })\n )\n }\n\n get allowClear() {\n return this.hasAttribute(\"allow-clear\")\n }\n\n get selection() {\n return this.getAttribute(\"selection\")\n }\n\n get placeholder() {\n return this.getAttribute(\"placeholder\") ?? \"\"\n }\n\n get queryParams() {\n return this.getAttribute(\"query-params\") ?? \"{}\"\n }\n\n get url() {\n return this.getAttribute(\"url\") ?? \"\"\n }\n\n get input() {\n return this.getElementsByTagName(\"input\")[0]\n }\n\n get select2Config() {\n return {\n placeholder: this.placeholder,\n allowClear: this.allowClear,\n initSelection: (_$el, callback) => {\n if (this.selection) {\n callback(JSON.parse(this.selection))\n }\n },\n ajax: this.ajaxConfig,\n formatSelection: (item) => this._renderResult(item),\n formatResult: (item, _el, query) =>\n this._renderListEntry(item, query.term)\n }\n }\n\n /**\n * Ajax configuration for Select2\n * @returns {object}\n */\n get ajaxConfig() {\n return {\n url: this.url,\n datatype: \"json\",\n quietMillis: 300,\n data: (term, page) => this._searchQuery(term, page),\n results: (response) => this._parseResponse(response)\n }\n }\n\n /**\n * Search query send to server from select2\n * @param {string} term\n * @param {number} page\n * @returns {object}\n * @private\n */\n _searchQuery(term, page) {\n return {\n q: {\n name_cont: term,\n ...JSON.parse(this.queryParams)\n },\n page: page\n }\n }\n\n /**\n * Parses server response into select2 results object\n * @param {object} response\n * @returns {object}\n * @private\n */\n _parseResponse(response) {\n const meta = response.meta\n return {\n results: response.data,\n more: meta.page * meta.per_page < meta.total_count\n }\n }\n\n /**\n * result which is visible if a page was selected\n * @param {object} item\n * @returns {string}\n * @private\n */\n _renderResult() {\n throw new Error(\n \"You need to define a _renderResult function on your sub class!\"\n )\n }\n\n /**\n * html template for each list entry\n * @param {object} item\n * @param {string} term\n * @returns {string}\n * @private\n */\n _renderListEntry() {\n throw new Error(\n \"You need to define a _renderListEntry function on your sub class!\"\n )\n }\n\n /**\n * hightlighted search term\n * @param {string} name\n * @param {string} term\n * @returns {string}\n * @private\n */\n _hightlightTerm(name, term) {\n return hightlightTerm(name, term)\n }\n}\n","import {\n RemoteSelect,\n escapeHtml\n} from \"alchemy_admin/components/remote_select\"\n\nexport class AttachmentSelect extends RemoteSelect {\n _renderResult(item) {\n return this._renderListEntry(item)\n }\n\n /**\n * html template for each list entry\n * @param {object} page\n * @param {string} term\n * @returns {string}\n * @private\n */\n _renderListEntry(attachment, term) {\n return `\n
    \n \n ${this._hightlightTerm(attachment.name, term)}\n
    \n `\n }\n}\n\ncustomElements.define(\"alchemy-attachment-select\", AttachmentSelect)\n","// Dispatch a submit event on change of input or select elements\n// contained in a form, so that Turbo can submit the form.\nclass AutoSubmit extends HTMLElement {\n connectedCallback() {\n // Still using jQuery here, because select2 does not emit\n // the event from the original select element.\n $(this).on(\"change\", this.#onChange)\n }\n\n disconnectedCallback() {\n $(this).off(\"change\", this.#onChange)\n }\n\n #onChange = (event) => {\n // We need to dispatch a submit event, so that Turbo that listens\n // to it submits the search form us.\n const submitEvent = new Event(\"submit\", {\n bubbles: true,\n cancelable: true\n })\n event.target.form.dispatchEvent(submitEvent)\n return false\n }\n}\n\ncustomElements.define(\"alchemy-auto-submit\", AutoSubmit)\n","import Spinner from \"alchemy_admin/spinner\"\n\nclass Button extends HTMLButtonElement {\n connectedCallback() {\n if (this.form) {\n this.form.addEventListener(\"submit\", this)\n\n if (this.form.dataset.remote == \"true\") {\n this.form.addEventListener(\"ajax:complete\", this)\n }\n\n this.form.addEventListener(\"turbo:submit-end\", this)\n } else {\n console.warn(\"No form for button found!\", this)\n }\n }\n\n handleEvent(event) {\n switch (event.type) {\n case \"submit\":\n const isDisabled = this.getAttribute(\"disabled\") === \"disabled\"\n\n if (isDisabled) {\n event.preventDefault()\n event.stopPropagation()\n } else {\n this.disable()\n }\n break\n case \"ajax:complete\":\n case \"turbo:submit-end\":\n this.enable()\n break\n }\n }\n\n disable() {\n const spinner = new Spinner(\"small\")\n const rect = this.getBoundingClientRect()\n\n this.dataset.initialButtonText = this.innerHTML\n this.setAttribute(\"disabled\", \"disabled\")\n this.setAttribute(\"tabindex\", \"-1\")\n this.classList.add(\"disabled\")\n this.style.width = `${rect.width}px`\n this.style.height = `${rect.height}px`\n this.innerHTML = \" \"\n\n spinner.spin(this)\n }\n\n enable() {\n this.classList.remove(\"disabled\")\n this.removeAttribute(\"disabled\")\n this.removeAttribute(\"tabindex\")\n this.style.width = null\n this.style.height = null\n this.innerHTML = this.dataset.initialButtonText\n }\n}\n\ncustomElements.define(\"alchemy-button\", Button, { extends: \"button\" })\n","/**\n * Show the character counter below input fields and textareas\n */\nimport { translate } from \"alchemy_admin/i18n\"\n\nclass CharCounter extends HTMLElement {\n connectedCallback() {\n this.translation = translate(\"allowed_chars\", this.maxChars)\n this.formField = this.getFormField()\n\n if (this.formField) {\n this.createDisplayElement()\n this.countCharacters()\n this.formField.addEventListener(\"keyup\", this)\n }\n }\n\n disconnectedCallback() {\n this.formField?.removeEventListener(\"keyup\", this)\n }\n\n handleEvent(event) {\n if (event.type === \"keyup\") this.countCharacters()\n }\n\n getFormField() {\n const formFields = this.querySelectorAll(\"input, textarea\")\n return formFields.length > 0 ? formFields[0] : undefined\n }\n\n createDisplayElement() {\n this.display = this.querySelector(\":scope > .alchemy-char-counter\")\n if (this.display) return\n this.display = document.createElement(\"small\")\n this.display.className = \"alchemy-char-counter\"\n this.formField.after(this.display)\n }\n\n countCharacters() {\n const charLength = this.formField.value.length\n this.display.textContent = `${charLength} ${this.translation}`\n this.display.classList.toggle(\"too-long\", charLength > this.maxChars)\n }\n\n get maxChars() {\n return this.getAttribute(\"max-chars\") ?? 60\n }\n}\n\ncustomElements.define(\"alchemy-char-counter\", CharCounter)\n","import \"clipboard\"\nimport { growl } from \"alchemy_admin/growler\"\n\nclass ClipboardButton extends HTMLElement {\n connectedCallback() {\n this.innerHTML = ''\n\n this.clipboard = new ClipboardJS(this, {\n text: () => {\n return this.getAttribute(\"content\")\n }\n })\n\n this.clipboard.on(\"success\", () => {\n growl(this.getAttribute(\"success-text\"))\n })\n }\n\n disconnectedCallback() {\n this.clipboard.destroy()\n }\n}\n\ncustomElements.define(\"alchemy-clipboard-button\", ClipboardButton)\n","const formatItem = (object) => {\n const optionEl = object.element[0]\n const swatch = optionEl.dataset.swatch || optionEl.value\n const customColor = optionEl.value === \"custom_color\"\n const colorIndicator = customColor\n ? ``\n : ``\n\n return `\n
    \n ${colorIndicator}\n ${object.text}\n
    `\n}\n\nclass ColorSelect extends HTMLElement {\n #select2 = null\n\n connectedCallback() {\n if (this.select) {\n this.#initializeSelect2()\n this.#select2.on(\"change\", this.#onSelectChange)\n } else {\n this.colorInput?.addEventListener(\"input\", this)\n this.textInput?.addEventListener(\"input\", this)\n this.#toggleColorPicker(true)\n }\n }\n\n handleEvent(event) {\n switch (event.target) {\n case this.colorInput:\n this.textInput.value = this.colorInput.value\n break\n case this.textInput:\n this.colorInput.value = this.textInput.value\n break\n }\n }\n\n disconnectedCallback() {\n this.colorInput?.removeEventListener(\"input\", this)\n this.textInput?.removeEventListener(\"input\", this)\n if (this.#select2) {\n this.#select2.off(\"change\", this.#onSelectChange)\n this.#select2.select2(\"destroy\")\n this.#select2 = null\n }\n }\n\n #onSelectChange = (event) => {\n this.#toggleColorPicker(event.val === \"custom_color\")\n }\n\n #initializeSelect2() {\n this.select.classList.add(\"alchemy_selectbox\")\n const options = {\n minimumResultsForSearch: 10,\n formatResult: formatItem,\n formatSelection: formatItem\n }\n this.#select2 = $(this.select).select2(options)\n }\n\n #toggleColorPicker(enabled = true) {\n this.colorInput.disabled = !enabled\n }\n\n get colorInput() {\n return this.querySelector(\"input[type='color']\")\n }\n\n get textInput() {\n return this.querySelector(\"input[type='text']\")\n }\n\n get select() {\n return this.querySelector(\"select\")\n }\n}\n\ncustomElements.define(\"alchemy-color-select\", ColorSelect)\n","import { translate, currentLocale } from \"alchemy_admin/i18n\"\nimport flatpickr from \"flatpickr\"\n\nconst locale = currentLocale()\n\nclass Datepicker extends HTMLElement {\n // Load the locales for flatpickr before setting it up.\n async connectedCallback() {\n // English is the default locale for flatpickr, so we don't need to load it\n if (locale !== \"en\") {\n await import(`flatpickr/${locale}.js`)\n }\n // Bail out if the element was disconnected while the locale was loading.\n // Otherwise flatpickr would leak a calendar onto a detached input.\n if (!this.isConnected) return\n\n this.flatpickr = flatpickr(this.inputField, this.flatpickrOptions)\n }\n\n disconnectedCallback() {\n this.flatpickr?.destroy()\n }\n\n get flatpickrOptions() {\n const enableTime = /time/.test(this.inputType)\n const options = {\n // alchemy_i18n supports `zh_CN` etc., but flatpickr only has two-letter codes (`zh`)\n locale: locale.slice(0, 2),\n altInput: true,\n altFormat: translate(`formats.${this.inputType}`),\n altInputClass: \"flatpickr-input\",\n enableTime,\n noCalendar: this.inputType === \"time\",\n time_24hr: translate(\"formats.time_24hr\"),\n onValueUpdate(_selectedDates, _dateStr, instance) {\n instance.element\n .closest(\"alchemy-element-editor\")\n ?.setDirty(this.inputField)\n }\n }\n\n if (enableTime) {\n options.dateFormat = \"Z\"\n }\n\n return options\n }\n\n get inputField() {\n return this.querySelector(\"input\")\n }\n\n get inputType() {\n return this.getAttribute(\"input-type\") || \"date\"\n }\n}\n\ncustomElements.define(\"alchemy-datepicker\", Datepicker)\n","import { Dialog } from \"alchemy_admin/dialog\"\n\nexport class DialogLink extends HTMLAnchorElement {\n connectedCallback() {\n this.addEventListener(\"click\", this)\n }\n\n disconnectedCallback() {\n this.removeEventListener(\"click\", this)\n }\n\n handleEvent(evt) {\n if (!this.disabled) {\n this.openDialog()\n }\n evt.preventDefault()\n }\n\n openDialog() {\n this.dialog = new Dialog(this.getAttribute(\"href\"), this.dialogOptions)\n this.dialog.open()\n }\n\n get dialogOptions() {\n const options = this.dataset.dialogOptions\n ? JSON.parse(this.dataset.dialogOptions)\n : {}\n return options\n }\n\n get disabled() {\n return this.classList.contains(\"disabled\")\n }\n}\n\ncustomElements.define(\"alchemy-dialog-link\", DialogLink, { extends: \"a\" })\n","const JSON_CONTENT_TYPE = \"application/json\"\nconst TURBO_STREAM_CONTENT_TYPE = \"text/vnd.turbo-stream.html\"\n\nfunction isGetRequest(method) {\n return method.toLowerCase() === \"get\"\n}\n\nfunction prepareURL(path, data, method) {\n const url = new URL(window.location.origin + path)\n\n if (data && isGetRequest(method)) {\n url.search = new URLSearchParams(data).toString()\n }\n\n return url.toString()\n}\n\nfunction prepareHeaders(accept) {\n return {\n \"Content-Type\": \"application/json; charset=utf-8\",\n Accept: accept,\n \"X-Requested-With\": \"XMLHttpRequest\",\n \"X-CSRF-Token\": getToken()\n }\n}\n\nfunction prepareOptions(method, data, accept) {\n const headers = prepareHeaders(accept)\n const options = { method, headers }\n\n if (data && !isGetRequest(method)) {\n options.body = JSON.stringify(data)\n }\n\n return options\n}\n\nexport function getToken() {\n const metaTag = document.querySelector('meta[name=\"csrf-token\"]')\n return metaTag.attributes.content.textContent\n}\n\nexport function get(url, params) {\n return ajax(\"GET\", url, params)\n}\n\nexport function patch(url, data, accept) {\n return ajax(\"PATCH\", url, data, accept)\n}\n\nexport function post(url, data, accept = JSON_CONTENT_TYPE) {\n return ajax(\"POST\", url, data, accept)\n}\n\nexport default async function ajax(\n method,\n path,\n data,\n accept = JSON_CONTENT_TYPE\n) {\n const response = await fetch(\n prepareURL(path, data, method),\n prepareOptions(method, data, accept)\n )\n const contentType = response.headers.get(\"content-type\")\n const isJson = contentType?.includes(JSON_CONTENT_TYPE)\n const isTurboStream = contentType?.includes(TURBO_STREAM_CONTENT_TYPE)\n\n let responseData = null\n if (isJson) {\n responseData = await response.json()\n } else if (isTurboStream) {\n responseData = await response.text()\n // Automatically render Turbo Stream if Turbo is available\n if (typeof Turbo !== \"undefined\") {\n Turbo.renderStreamMessage(responseData)\n }\n }\n\n if (response.ok) {\n return { data: responseData, status: response.status }\n } else {\n throw responseData || new Error(\"An error occurred during the transaction\")\n }\n}\n","import { get } from \"alchemy_admin/utils/ajax\"\nimport { translate } from \"alchemy_admin/i18n\"\n\nclass DomIdSelect extends HTMLElement {\n dataItem(hash) {\n return {\n id: `#${hash}`,\n text: `#${hash}`\n }\n }\n\n get selectElement() {\n return this.querySelector('select[is=\"alchemy-select\"]')\n }\n}\n\nclass DomIdApiSelect extends DomIdSelect {\n #pageId = undefined\n\n connectedCallback() {\n this.page = this.getAttribute(\"page\")\n }\n\n async #fetchDomIds() {\n const result = await get(Alchemy.routes.api_ingredients_path, {\n page_id: this.#pageId\n })\n const options = result.data.ingredients\n .filter((ingredient) => ingredient.data?.dom_id)\n .map((ingredient) => this.dataItem(ingredient.data.dom_id))\n const prompt =\n options.length > 0 ? translate(\"None\") : translate(\"No anchors found\")\n\n this.selectElement.setOptions(options, prompt)\n this.selectElement.enable()\n }\n\n #reset() {\n // wait a tick to initialize the alchemy-select\n requestAnimationFrame(() => {\n this.selectElement.disable()\n this.selectElement.setOptions([], translate(\"Select a page first\"))\n })\n }\n\n set page(pageId) {\n this.#pageId = pageId\n pageId ? this.#fetchDomIds() : this.#reset()\n }\n}\n\nclass DomIdPreviewSelect extends DomIdSelect {\n connectedCallback() {\n // wait a tick to let the browser initialize the inner select component\n requestAnimationFrame(() => {\n const frame = document.getElementById(\"alchemy_preview_window\")\n const elements = frame.contentDocument?.querySelectorAll(\"[id]\") || []\n if (elements.length > 0) {\n const options = Array.from(elements).map((element) => {\n return this.dataItem(element.id)\n })\n this.selectElement.setOptions(options, translate(\"None\"))\n }\n })\n }\n}\n\ncustomElements.define(\"alchemy-dom-id-api-select\", DomIdApiSelect)\ncustomElements.define(\"alchemy-dom-id-preview-select\", DomIdPreviewSelect)\n","export function dispatchPageDirtyEvent(data) {\n document.dispatchEvent(\n new CustomEvent(\"alchemy:page-dirty\", {\n detail: { tooltip: data.publishButtonTooltip }\n })\n )\n}\n","export class PublishElementButton extends HTMLElement {\n #scheduleButtonVariant\n\n connectedCallback() {\n this.#scheduleButtonVariant = this.scheduleButton.getAttribute(\"variant\")\n this.publishButton.addEventListener(\"click\", this)\n this.dropdown.addEventListener(\"sl-show\", this)\n this.dropdown.addEventListener(\"sl-hide\", this)\n }\n\n disconnectedCallback() {\n this.publishButton.removeEventListener(\"click\", this)\n this.dropdown.removeEventListener(\"sl-show\", this)\n this.dropdown.removeEventListener(\"sl-hide\", this)\n }\n\n handleEvent(event) {\n switch (event.type) {\n case \"click\":\n this.publishButton.loading = true\n break\n case \"sl-show\":\n this.scheduleButton.setAttribute(\"variant\", \"primary\")\n break\n case \"sl-hide\":\n this.scheduleButton.setAttribute(\"variant\", this.#scheduleButtonVariant)\n break\n }\n }\n\n get publishButton() {\n return this.querySelector(\"sl-button[type='submit']\")\n }\n\n get dropdown() {\n return this.querySelector(\"sl-dropdown\")\n }\n\n get scheduleButton() {\n return this.querySelector(\"sl-button[slot='trigger']\")\n }\n}\n\ncustomElements.define(\"alchemy-publish-element-button\", PublishElementButton)\n","import ajax from \"alchemy_admin/utils/ajax\"\nimport { removeTab } from \"alchemy_admin/fixed_elements\"\nimport { growl } from \"alchemy_admin/growler\"\nimport { reloadPreview } from \"alchemy_admin/components/preview_window\"\nimport { openConfirmDialog } from \"alchemy_admin/confirm_dialog\"\nimport { dispatchPageDirtyEvent } from \"alchemy_admin/utils/dispatch_page_dirty_event\"\n\nexport class DeleteElementButton extends HTMLElement {\n connectedCallback() {\n this.button?.addEventListener(\"click\", this)\n }\n\n disconnectedCallback() {\n this.button?.removeEventListener(\"click\", this)\n }\n\n async handleEvent() {\n const confirmed = await openConfirmDialog(this.message)\n if (confirmed) {\n const response = await ajax(\"DELETE\", this.url)\n this.#removeElement(response.data)\n }\n }\n\n #removeElement(data) {\n const elementEditor = this.closest(\"alchemy-element-editor\")\n elementEditor.addEventListener(\"transitionend\", () => {\n if (elementEditor.fixed) {\n removeTab(elementEditor.elementId)\n }\n elementEditor.remove()\n })\n elementEditor.classList.add(\"dismiss\")\n growl(data.message)\n if (data.pageHasUnpublishedChanges) {\n dispatchPageDirtyEvent(data)\n }\n reloadPreview()\n }\n\n get url() {\n return this.getAttribute(\"href\")\n }\n\n get message() {\n return this.getAttribute(\"message\")\n }\n\n get button() {\n return this.querySelector(\"button\")\n }\n}\n\ncustomElements.define(\"alchemy-delete-element-button\", DeleteElementButton)\n","import IngredientAnchorLink from \"alchemy_admin/ingredient_anchor_link\"\nimport { post } from \"alchemy_admin/utils/ajax\"\nimport { createHtmlElement } from \"alchemy_admin/utils/dom_helpers\"\nimport { dispatchPageDirtyEvent } from \"alchemy_admin/utils/dispatch_page_dirty_event\"\nimport { growl } from \"alchemy_admin/growler\"\n\nimport \"alchemy_admin/components/element_editor/publish_element_button\"\nimport \"alchemy_admin/components/element_editor/delete_element_button\"\n\nexport class ElementEditor extends HTMLElement {\n #form = null\n #header = null\n #toggleButton = null\n\n connectedCallback() {\n // The placeholder while be being dragged is empty.\n if (this.classList.contains(\"ui-sortable-placeholder\")) {\n return\n }\n\n // Add event listeners\n this.addEventListener(\"click\", this)\n // Triggered by child elements\n this.addEventListener(\"alchemy:element-update-title\", this)\n // We use of @rails/ujs for Rails remote forms\n this.addEventListener(\"ajax:complete\", this)\n\n // Dirty observer still needs to be jQuery\n // in order to support select2.\n this.#form = this.form\n if (this.#form) {\n $(this.#form).on(\"change\", this.onChange)\n }\n\n this.#header = this.header\n this.#header?.addEventListener(\"dblclick\", this.#onHeaderDblclick)\n\n this.#toggleButton = this.toggleButton\n this.#toggleButton?.addEventListener(\"click\", this.#onToggleClick)\n\n // When newly created, focus the element and refresh the preview\n if (this.hasAttribute(\"created\")) {\n this.focusElement()\n this.previewWindow?.refresh().then(() => {\n this.focusElementPreview()\n })\n this.removeAttribute(\"created\")\n }\n }\n\n disconnectedCallback() {\n this.removeEventListener(\"click\", this)\n this.removeEventListener(\"alchemy:element-update-title\", this)\n this.removeEventListener(\"ajax:complete\", this)\n if (this.#form) {\n $(this.#form).off(\"change\", this.onChange)\n this.#form = null\n }\n this.#header?.removeEventListener(\"dblclick\", this.#onHeaderDblclick)\n this.#header = null\n this.#toggleButton?.removeEventListener(\"click\", this.#onToggleClick)\n this.#toggleButton = null\n }\n\n handleEvent(event) {\n switch (event.type) {\n case \"click\":\n const elementEditor = event.target.closest(\"alchemy-element-editor\")\n if (elementEditor === this) {\n this.onClickElement()\n }\n break\n case \"ajax:complete\":\n if (event.target === this.body) {\n const xhr = event.detail[0]\n event.stopPropagation()\n this.onSaveElement(xhr)\n }\n break\n case \"alchemy:element-update-title\":\n if (!this.hasEditors && event.target == this.firstChild) {\n this.setTitle(event.detail.title)\n }\n break\n }\n }\n\n onChange = (event) => {\n const target = event.target\n // SortableJS fires a native change event :/\n // and we do not want to set the element editor dirty\n // when this happens\n if (target.classList.contains(\"nested-elements\")) {\n return\n }\n this.setDirty(target)\n event.stopPropagation()\n return false\n }\n\n /**\n * Scrolls to and highlights element\n * Expands if collapsed\n * Also chooses the right fixed elements tab, if necessary.\n * Can be triggered through custom event 'FocusElementEditor.Alchemy'\n * Used by the elements on click events in the preview frame.\n */\n async focusElement() {\n // Select tab if necessary\n if (document.querySelector(\"#fixed-elements\")) {\n await this.selectTabForElement()\n }\n // Expand if necessary\n await this.expand()\n this.selectElement(true)\n }\n\n focusElementPreview() {\n this.previewWindow?.postMessage({\n message: \"Alchemy.focusElement\",\n element_id: this.elementId\n })\n }\n\n onClickElement() {\n this.selectElement()\n this.focusElementPreview()\n }\n\n /**\n * Sets the element to saved state\n * Updates title\n * JS event bubbling will also update the parents element quote.\n * Shows error messages if ingredient validations fail\n * @argument {XMLHttpRequest} xhr\n */\n onSaveElement(xhr) {\n const data = JSON.parse(xhr.responseText)\n // Reset errors that might be visible from last save attempt\n this.setClean()\n // If validation failed\n if (xhr.status === 422) {\n const warning = data.warning\n // Create error messages\n // Mark ingredients as failed\n data.ingredientsWithErrors.forEach((ingredient) => {\n const ingredientEditor = this.querySelector(\n `[data-ingredient-id=\"${ingredient.id}\"]`\n )\n const errorDisplay = createHtmlElement(\n `${ingredient.errorMessage}`\n )\n ingredientEditor?.appendChild(errorDisplay)\n ingredientEditor?.classList.add(\"validation_failed\")\n })\n // Show message\n growl(warning, \"warn\")\n this.elementErrors.classList.remove(\"hidden\")\n } else {\n growl(data.notice)\n this.previewWindow?.refresh().then(() => {\n this.focusElementPreview()\n })\n this.updateTitle(data.previewText)\n data.ingredientAnchors.forEach((anchor) => {\n IngredientAnchorLink.updateIcon(anchor.ingredientId, anchor.active)\n })\n if (data.pageHasUnpublishedChanges) {\n dispatchPageDirtyEvent(data)\n }\n }\n }\n\n /**\n * Smoothly scrolls to element\n */\n scrollToElement() {\n // The timeout gives the browser some time to calculate the position\n // of nested elements correctly\n setTimeout(() => {\n this.scrollIntoView({\n behavior: \"smooth\"\n })\n }, 50)\n }\n\n /**\n * Highlight element and optionally scroll into view\n * @param {boolean} scroll smoothly scroll element into view. Default (false)\n */\n selectElement(scroll = false) {\n document\n .querySelectorAll(\"alchemy-element-editor.selected\")\n .forEach((el) => {\n el.classList.remove(\"selected\")\n })\n window.requestAnimationFrame(() => {\n this.classList.add(\"selected\")\n })\n if (scroll) this.scrollToElement()\n }\n\n /**\n * Selects tab for given element\n * Resolves the promise if this is done.\n * @returns {Promise}\n */\n selectTabForElement() {\n return new Promise((resolve, reject) => {\n const tabs = document.querySelector(\"#fixed-elements\")\n const panel = this.closest(\"sl-tab-panel\")\n if (tabs && panel) {\n tabs.show(panel.getAttribute(\"name\"))\n resolve()\n } else {\n reject(new Error(\"No tabs present\"))\n }\n })\n }\n\n /**\n * Sets the element into clean (safed) state\n */\n setClean() {\n this.dirty = false\n window.onbeforeunload = null\n this.elementErrors.classList.add(\"hidden\")\n\n if (this.hasEditors) {\n this.body.querySelectorAll(\".ingredient-editor\").forEach((el) => {\n el.classList.remove(\"dirty\", \"validation_failed\")\n el.querySelectorAll(\"small.error\").forEach((e) => e.remove())\n })\n }\n }\n\n /**\n * Sets the element into dirty (unsafed) state\n * @param {HTMLElement} editor\n */\n setDirty(editor) {\n if (this.hasEditors) {\n this.dirty = true\n\n if (!window.onbeforeunload) {\n window.onbeforeunload = (event) => event.preventDefault()\n }\n\n editor?.closest(\".ingredient-editor\")?.classList.add(\"dirty\")\n }\n }\n\n /**\n * Sets the title quote\n * @param {string} title\n */\n setTitle(title) {\n const quote = this.querySelector(\".element-header .preview_text_quote\")\n // Fixed elements have no header, so there is no quote to update.\n if (quote) {\n quote.textContent = title\n }\n }\n\n /**\n * Expands or collapses element editor\n * If the element is dirty (has unsaved changes) it displays a confirm first.\n */\n async toggle() {\n if (this.collapsed) {\n await this.expand()\n } else {\n await this.collapse()\n }\n }\n\n /**\n * Collapses the element editor and persists the state on the server\n * @returns {Promise}\n */\n collapse() {\n if (this.collapsed || this.compact || this.fixed) {\n return Promise.resolve(\"Element is already collapsed.\")\n }\n\n const spinner = new Alchemy.Spinner(\"small\")\n spinner.spin(this.toggleButton)\n this.toggleIcon?.classList?.add(\"hidden\")\n return post(Alchemy.routes.collapse_admin_element_path(this.elementId))\n .then((response) => {\n const data = response.data\n\n this.collapsed = true\n this.toggleButton?.setAttribute(\"title\", data.title)\n\n // Collapse all nested elements if necessarry\n if (data.nestedElementIds.length) {\n const selector = data.nestedElementIds\n .map((id) => `#element_${id}`)\n .join(\", \")\n this.querySelectorAll(selector).forEach((nestedElement) => {\n nestedElement.collapsed = true\n nestedElement.toggleButton?.setAttribute(\"title\", data.title)\n })\n }\n })\n .catch((error) => {\n growl(error.message, \"error\")\n console.error(error)\n })\n .finally(() => {\n this.toggleIcon?.classList?.remove(\"hidden\")\n spinner.stop()\n })\n }\n\n /**\n * Collapses the element editor and persists the state on the server\n * @* @returns {Promise}\n */\n expand() {\n if (this.expanded && !this.compact) {\n return Promise.resolve(\"Element is already expanded.\")\n }\n\n if (this.compact && this.parentElementEditor) {\n return this.parentElementEditor.expand()\n } else {\n const spinner = new Alchemy.Spinner(\"small\")\n spinner.spin(this.toggleButton)\n this.toggleIcon?.classList.add(\"hidden\")\n\n return new Promise((resolve, reject) => {\n post(Alchemy.routes.expand_admin_element_path(this.elementId))\n .then((response) => {\n const data = response.data\n\n // First expand all parent elements if necessary\n if (data.parentElementIds.length) {\n const selector = data.parentElementIds\n .map((id) => `#element_${id}`)\n .join(\", \")\n document.querySelectorAll(selector).forEach((parentElement) => {\n parentElement.collapsed = false\n parentElement.toggleButton?.setAttribute(\"title\", data.title)\n })\n }\n // Finally expand ourselve\n this.collapsed = false\n this.toggleButton?.setAttribute(\"title\", data.title)\n // Resolve the promise that scrolls to the element very last\n resolve()\n })\n .catch((error) => {\n growl(error.message, \"error\")\n console.error(error)\n reject(error)\n })\n .finally(() => {\n this.toggleIcon?.classList?.remove(\"hidden\")\n spinner.stop()\n })\n })\n }\n }\n\n /**\n * Updates the quote in the element header and dispatches event\n * to parent elements\n * @param {string} title\n */\n updateTitle(title) {\n this.setTitle(title)\n this.dispatchEvent(\n new CustomEvent(\"alchemy:element-update-title\", {\n bubbles: true,\n detail: { title }\n })\n )\n }\n\n /**\n * Sets element published or hidden\n * @param {boolean}\n */\n set published(isPublished) {\n if (isPublished) {\n this.classList.remove(\"element-hidden\")\n } else {\n this.classList.add(\"element-hidden\")\n }\n }\n\n /**\n * Is element published or hidden\n * @returns {boolean}\n */\n get published() {\n return !this.classList.contains(\"hidden\")\n }\n\n /**\n * @returns {boolean}\n */\n get compact() {\n return this.getAttribute(\"compact\") !== null\n }\n\n /**\n * @returns {boolean}\n */\n get fixed() {\n return this.getAttribute(\"fixed\") !== null\n }\n\n /**\n * @param {boolean} value\n */\n set collapsed(value) {\n this.classList.toggle(\"folded\", value)\n this.classList.toggle(\"expanded\", !value)\n this.toggleIcon &&\n (this.toggleIcon.name = value ? \"arrow-left-s\" : \"arrow-down-s\")\n }\n\n /**\n * @returns {boolean}\n */\n get collapsed() {\n return this.classList.contains(\"folded\")\n }\n\n /**\n * @returns {boolean}\n */\n get expanded() {\n return !this.collapsed\n }\n\n /**\n * Toggles the dirty class\n *\n * @param {boolean} value\n */\n set dirty(value) {\n this.classList.toggle(\"dirty\", value)\n }\n\n /**\n * Returns the dirty state of this element\n *\n * @returns {boolean}\n */\n get dirty() {\n return this.classList.contains(\"dirty\")\n }\n\n /**\n * Returns the element header\n *\n * @returns {HTMLElement|undefined}\n */\n get header() {\n return this.querySelector(`.element-header`)\n }\n\n /**\n * Returns the immediate body container of this element if present\n *\n * Makes sure it does not return a nested elements body\n * by scoping the selector to this elements id.\n *\n * @returns {HTMLElement|undefined}\n */\n get body() {\n return this.querySelector(this.bodySelector)\n }\n\n get bodySelector() {\n return `#${this.id} > .element-body`\n }\n\n /**\n * Returns the immediate footer container of this element if present\n *\n * Makes sure it does not return a nested elements footer\n * by scoping the selector to this elements id.\n *\n * @returns {HTMLElement|undefined}\n */\n get footer() {\n return this.querySelector(`#${this.id} > .element-footer`)\n }\n\n /**\n * The collapse/expand toggle button\n *\n * @returns {HTMLButtonElement|undefined}\n */\n get toggleButton() {\n return this.querySelector(\".element-toggle\")\n }\n\n /**\n * The collapse/expand toggle buttons icon\n *\n * @returns {HTMLElement|undefined}\n */\n get toggleIcon() {\n return this.toggleButton?.querySelector(\"alchemy-icon\")\n }\n\n /**\n * The validation messages list container\n *\n * @returns {HTMLElement}\n */\n get elementErrors() {\n return this.body.querySelector(\".element_errors\")\n }\n\n /**\n * The element database id\n *\n * @returns {string}\n */\n get elementId() {\n return this.dataset.elementId\n }\n\n /**\n * The element defintion name\n *\n * @returns {string}\n */\n get elementName() {\n return this.dataset.elementName\n }\n\n /**\n * Does this element have ingredient editor fields?\n *\n * @returns {boolean}\n */\n get hasEditors() {\n return !!this.body?.querySelector(\".element-ingredient-editors\")\n }\n\n /**\n * Does this element have nested elements?\n *\n * @returns {boolean}\n */\n get hasChildren() {\n return !!this.querySelector(\".nested-elements\")\n }\n\n /**\n * The first child element editor if present\n *\n * @returns {HTMLButtonElement|undefined}\n */\n get firstChild() {\n return this.querySelector(\"alchemy-element-editor\")\n }\n\n /**\n * The form element if present\n *\n * @returns {HTMLFormElement|undefined}\n */\n get form() {\n return this.querySelector(\"form.element-body\")\n }\n\n /**\n * The parent element editor if present\n *\n * @returns {ElementEditor|undefined}\n */\n get parentElementEditor() {\n return this.parentElement?.closest(\"alchemy-element-editor\")\n }\n\n get previewWindow() {\n return document.getElementById(\"alchemy_preview_window\")\n }\n\n #onHeaderDblclick = () => {\n this.toggle()\n }\n\n #onToggleClick = (evt) => {\n const elementEditor = evt.target.closest(\"alchemy-element-editor\")\n if (elementEditor === this) {\n this.toggle()\n }\n }\n}\n\ncustomElements.define(\"alchemy-element-editor\", ElementEditor)\n","import { hightlightTerm } from \"alchemy_admin/components/remote_select\"\n\nconst formatSelection = (option) => {\n return `\n
    \n ${option.icon}${option.text}\n
    \n `\n}\n\nconst formatItem = (icon, text, hint) => {\n const description = hint\n ? `
    ${hint}
    `\n : \"\"\n return `\n
    \n ${formatSelection({ icon, text })}\n ${description}\n
    \n `\n}\n\nexport class ElementSelect extends HTMLElement {\n #select2 = null\n\n connectedCallback() {\n const results = this.options\n const options = {\n minimumResultsForSearch: 3,\n dropdownAutoWidth: true,\n data() {\n return { results }\n },\n formatResult: (option, _el, search) => {\n let text\n\n if (option.id === \"\") return option.text\n if (search.term !== \"\") {\n text = hightlightTerm(option.text, search.term)\n } else {\n text = option.text\n }\n\n return formatItem(option.icon, text, option.hint)\n },\n formatSelection,\n placeholder: this.placeholder\n }\n this.#select2 = $(this.inputField).select2(options)\n }\n\n disconnectedCallback() {\n this.#select2?.select2(\"destroy\")\n this.#select2 = null\n }\n\n get options() {\n return JSON.parse(this.getAttribute(\"options\"))\n }\n\n get placeholder() {\n return this.getAttribute(\"placeholder\")\n }\n\n get inputField() {\n return this.querySelector(\"input\")\n }\n}\n\ncustomElements.define(\"alchemy-element-select\", ElementSelect)\n","import { ElementEditor } from \"alchemy_admin/components/element_editor\"\n\nclass ElementsWindow extends HTMLElement {\n #visible = true\n #turboFrame = null\n\n connectedCallback() {\n this.toggleButton?.addEventListener(\"click\", this.#onToggleClick)\n this.collapseButton?.addEventListener(\"click\", this.#onCollapseClick)\n window.addEventListener(\"message\", this.#onWindowMessage)\n document.body.addEventListener(\"click\", this.#onBodyClick)\n if (window.location.hash) {\n this.focusElementEditor(window.location.hash)\n }\n this.resize()\n }\n\n disconnectedCallback() {\n this.toggleButton?.removeEventListener(\"click\", this.#onToggleClick)\n this.collapseButton?.removeEventListener(\"click\", this.#onCollapseClick)\n window.removeEventListener(\"message\", this.#onWindowMessage)\n document.body.removeEventListener(\"click\", this.#onBodyClick)\n }\n\n #onToggleClick = (evt) => {\n evt.preventDefault()\n this.toggle()\n }\n\n #onCollapseClick = () => {\n this.collapseAllElements()\n }\n\n #onWindowMessage = (event) => {\n const data = event.data\n if (data?.message == \"Alchemy.focusElementEditor\") {\n const element = document.getElementById(`element_${data.element_id}`)\n this.show()\n element?.focusElement()\n }\n }\n\n #onBodyClick = (evt) => {\n if (!evt.target.closest(\"alchemy-element-editor\")) {\n this.querySelectorAll(\"alchemy-element-editor\").forEach((editor) => {\n editor.classList.remove(\"selected\")\n })\n this.previewWindow?.postMessage({ message: \"Alchemy.blurElements\" })\n }\n }\n\n collapseAllElements() {\n this.querySelectorAll(\n \"alchemy-element-editor:not([compact]):not([fixed])\"\n ).forEach((editor) => editor.collapse())\n }\n\n toggle() {\n this.#visible ? this.hide() : this.show()\n }\n\n show() {\n document.body.classList.add(\"elements-window-visible\")\n this.#visible = true\n this.toggleButton.closest(\"sl-tooltip\").content = Alchemy.t(\"Hide elements\")\n this.toggleButton\n .querySelector(\"alchemy-icon\")\n .setAttribute(\"name\", \"menu-unfold\")\n this.resize()\n }\n\n hide() {\n document.body.classList.remove(\"elements-window-visible\")\n document.body.style.removeProperty(\"--elements-window-width\")\n this.#visible = false\n this.toggleButton.closest(\"sl-tooltip\").content = Alchemy.t(\"Show elements\")\n this.toggleButton\n .querySelector(\"alchemy-icon\")\n .setAttribute(\"name\", \"menu-fold\")\n }\n\n resize(width) {\n if (width === undefined) {\n width = this.widthFromCookie\n }\n\n if (width) {\n document.body.style.setProperty(\"--elements-window-width\", `${width}px`)\n document.cookie = `alchemy-elements-window-width=${width}; SameSite=Lax; Path=/;`\n }\n }\n\n // Focus element editor and element in preview if URL contains hash to element editor.\n // The link is coming from the assignment view when showing assigned files or pictures.\n focusElementEditor(dom_id) {\n const el = document.querySelector(dom_id)\n\n if (el instanceof ElementEditor) {\n el.focusElement() && el.focusElementPreview()\n }\n }\n\n get collapseButton() {\n return this.querySelector(\"#collapse-all-elements-button\")\n }\n\n get toggleButton() {\n return document.querySelector(\"#element_window_button\")\n }\n\n get previewWindow() {\n return document.getElementById(\"alchemy_preview_window\")\n }\n\n get turboFrame() {\n if (!this.#turboFrame) {\n this.#turboFrame = this.closest(\"turbo-frame\")\n }\n return this.#turboFrame\n }\n\n get widthFromCookie() {\n return document.cookie\n .split(\"; \")\n .find((row) => row.startsWith(\"alchemy-elements-window-width=\"))\n ?.split(\"=\")[1]\n }\n\n set isDragged(dragged) {\n this.turboFrame.style.transitionProperty = dragged ? \"none\" : null\n this.turboFrame.style.pointerEvents = dragged ? \"none\" : null\n }\n}\n\ncustomElements.define(\"alchemy-elements-window\", ElementsWindow)\n","const MIN_WIDTH = 400\nconst MAX_WIDTH = 1000\nclass ElementsWindowHandle extends HTMLElement {\n #dragging = false\n #elementsWindow = null\n #previewWindow = null\n #minWidth = MIN_WIDTH\n #maxWidth = MAX_WIDTH\n\n connectedCallback() {\n this.addEventListener(\"mousedown\", this)\n window.addEventListener(\"mousemove\", this)\n window.addEventListener(\"mouseup\", this)\n }\n\n disconnectedCallback() {\n this.removeEventListener(\"mousedown\", this)\n window.removeEventListener(\"mousemove\", this)\n window.removeEventListener(\"mouseup\", this)\n }\n\n handleEvent(event) {\n switch (event.type) {\n case \"mousedown\":\n event.stopPropagation()\n this.onMouseDown()\n break\n case \"mouseup\":\n this.onMouseUp()\n break\n case \"mousemove\":\n if (this.#dragging) {\n this.onDrag(event.pageX)\n }\n break\n }\n }\n\n onMouseDown() {\n this.#dragging = true\n // Read the resolved min/max width the browser computed from the CSS\n // custom properties (incl. calc() and active media queries) once, so the\n // drag stays clamped to the same bounds the stylesheet defines.\n const styles = getComputedStyle(this.elementsWindow)\n this.#minWidth = parseFloat(styles.minWidth) || MIN_WIDTH\n this.#maxWidth =\n styles.maxWidth === \"none\" ? MAX_WIDTH : parseFloat(styles.maxWidth)\n this.elementsWindow.isDragged = true\n this.previewWindow.isDragged = true\n this.classList.add(\"is-dragged\")\n }\n\n onMouseUp() {\n this.#dragging = false\n this.elementsWindow.isDragged = false\n this.previewWindow.isDragged = false\n this.classList.remove(\"is-dragged\")\n }\n\n onDrag(pageX) {\n const elementWindowWidth = window.innerWidth - pageX\n const width = Math.min(\n Math.max(elementWindowWidth, this.#minWidth),\n this.#maxWidth\n )\n this.elementsWindow.resize(width)\n }\n\n get elementsWindow() {\n if (!this.#elementsWindow) {\n this.#elementsWindow = document.querySelector(\"alchemy-elements-window\")\n }\n return this.#elementsWindow\n }\n\n get previewWindow() {\n if (!this.#previewWindow) {\n this.#previewWindow = document.getElementById(\"alchemy_preview_window\")\n }\n return this.#previewWindow\n }\n}\n\ncustomElements.define(\"alchemy-elements-window-handle\", ElementsWindowHandle)\n","class FileEditor extends HTMLElement {\n connectedCallback() {\n this.deleteLink = this.querySelector(\".remove_file_link\")\n this.fileIcon = this.querySelector(\".file_icon\")\n this.fileName = this.querySelector(\".file_name\")\n this.formFieldId = this.deleteLink?.dataset.formFieldId\n this.formField = this.querySelector(`#${this.formFieldId}`)\n this.deleteLink?.addEventListener(\"click\", this)\n }\n\n disconnectedCallback() {\n this.deleteLink?.removeEventListener(\"click\", this)\n }\n\n handleEvent(event) {\n if (event.type === \"click\") this.removeFile()\n event.stopPropagation()\n }\n\n removeFile() {\n this.formField.value = \"\"\n this.fileIcon.innerHTML = \"\"\n this.fileName.innerHTML = \"\"\n this.deleteLink?.classList.add(\"hidden\")\n this.closest(\"alchemy-element-editor\").setDirty(this.formField)\n }\n}\n\ncustomElements.define(\"alchemy-file-editor\", FileEditor)\n","const DEFAULT_DEBOUNCE_TIME = 150\n\nclass ListFilter extends HTMLElement {\n #debounceTimer\n #filterField = null\n #clearButton = null\n\n connectedCallback() {\n if (this.hotkey) {\n key(this.hotkey, this.#onHotkey)\n }\n this.#filterField = this.filterField\n this.#filterField.addEventListener(\"keyup\", this.#onKeyup)\n this.#filterField.addEventListener(\"focus\", this.#onFocus)\n\n this.#clearButton = this.clearButton\n this.#clearButton.addEventListener(\"click\", this.#onClearClick)\n\n key(\"esc\", \"list_filter\", this.#onEscape)\n }\n\n disconnectedCallback() {\n clearTimeout(this.#debounceTimer)\n this.#filterField?.removeEventListener(\"keyup\", this.#onKeyup)\n this.#filterField?.removeEventListener(\"focus\", this.#onFocus)\n this.#filterField = null\n this.#clearButton?.removeEventListener(\"click\", this.#onClearClick)\n this.#clearButton = null\n if (this.hotkey) {\n key.unbind(this.hotkey)\n }\n key.unbind(\"esc\", \"list_filter\")\n }\n\n filter(term) {\n if (term === \"\") {\n this.clearButton.style.visibility = \"hidden\"\n }\n\n const matchedItems = []\n const itemsToShow = new Set()\n const lowerTerm = term.toLowerCase()\n\n // First pass: find matching items and mark their ancestors as visible too\n this.items.forEach((item) => {\n const name = item.getAttribute(this.nameAttribute)?.toLowerCase()\n // indexOf is much faster then match()\n if (name.indexOf(lowerTerm) !== -1) {\n matchedItems.push(item)\n itemsToShow.add(item)\n // Mark ancestor items as visible so nested matches stay visible\n let ancestor = item.parentElement?.closest(this.itemsSelector)\n while (ancestor) {\n itemsToShow.add(ancestor)\n ancestor = ancestor.parentElement?.closest(this.itemsSelector)\n }\n }\n })\n\n // Second pass: apply visibility\n this.items.forEach((item) => {\n item.classList.toggle(\"hidden\", !itemsToShow.has(item))\n })\n\n // Scroll into view if only one match\n if (matchedItems.length === 1) {\n matchedItems[0].scrollIntoView({ behavior: \"smooth\", block: \"nearest\" })\n }\n }\n\n clear() {\n this.filterField.value = \"\"\n this.clearButton.style.visibility = \"hidden\"\n this.items.forEach((item) => item.classList.remove(\"hidden\"))\n }\n\n get nameAttribute() {\n return this.getAttribute(\"name-attribute\") || \"name\"\n }\n\n get clearButton() {\n return this.querySelector('button[type=\"button\"]')\n }\n\n get filterField() {\n return this.querySelector('input[type=\"text\"]')\n }\n\n get items() {\n return document.querySelectorAll(this.itemsSelector)\n }\n\n get itemsSelector() {\n return this.getAttribute(\"items-selector\")\n }\n\n get debounceTime() {\n return parseInt(this.getAttribute(\"debounce-time\")) || DEFAULT_DEBOUNCE_TIME\n }\n\n get hotkey() {\n return this.getAttribute(\"hotkey\")\n }\n\n #onHotkey = () => {\n this.filterField.focus()\n return false\n }\n\n #onKeyup = () => {\n clearTimeout(this.#debounceTimer)\n this.#debounceTimer = setTimeout(() => {\n const term = this.filterField.value\n this.clearButton.style.visibility = term ? \"visible\" : \"hidden\"\n this.filter(term)\n }, this.debounceTime)\n }\n\n #onFocus = () => key.setScope(\"list_filter\")\n\n #onClearClick = (e) => {\n e.preventDefault()\n this.clear()\n }\n\n #onEscape = () => {\n this.clear()\n this.filterField.blur()\n }\n}\n\ncustomElements.define(\"alchemy-list-filter\", ListFilter)\n","const DISMISS_DELAY = 5000\n\nclass Message extends HTMLElement {\n #dismissTimeoutId = null\n\n handleEvent(event) {\n if (event.type === \"click\") {\n this.dismiss()\n }\n }\n\n connectedCallback() {\n if (!this.querySelector(\":scope > alchemy-icon\")) {\n const closeIcon =\n this.dismissable && this.type === \"error\"\n ? ''\n : \"\"\n this.insertAdjacentHTML(\n \"afterbegin\",\n `${closeIcon}`\n )\n }\n if (this.dismissable || this.type === \"error\") {\n this.addEventListener(\"click\", this)\n }\n if (this.dismissable && this.type !== \"error\") {\n this.#dismissTimeoutId = setTimeout(() => {\n this.dismiss()\n }, this.dismissDelay)\n }\n }\n\n disconnectedCallback() {\n if (this.#dismissTimeoutId !== null) {\n clearTimeout(this.#dismissTimeoutId)\n this.#dismissTimeoutId = null\n }\n }\n\n dismiss() {\n this.addEventListener(\"transitionend\", () => this.remove())\n this.classList.add(\"dismissed\")\n }\n\n get dismissable() {\n return this.hasAttribute(\"dismissable\")\n }\n\n get icon() {\n return this.getAttribute(\"icon\")\n }\n\n get type() {\n return this.getAttribute(\"type\") || \"notice\"\n }\n\n get dismissDelay() {\n return parseInt(\n this.noticesWrapper?.dataset.autoDismissDelay || DISMISS_DELAY\n )\n }\n\n get iconName() {\n switch (this.icon || this.type) {\n case \"warning\":\n case \"warn\":\n case \"alert\":\n return \"alert\"\n case \"notice\":\n return \"check\"\n case \"info\":\n case \"hint\":\n return \"information\"\n case \"error\":\n return \"bug\"\n default:\n return this.type\n }\n }\n\n get noticesWrapper() {\n return this.closest(\"#flash_notices\")\n }\n}\n\ncustomElements.define(\"alchemy-message\", Message)\n","import { growl } from \"alchemy_admin/growler\"\n\nclass Growl extends HTMLElement {\n connectedCallback() {\n growl(this.message, this.getAttribute(\"type\") || \"notice\")\n this.remove()\n }\n\n get message() {\n return this.getAttribute(\"message\") || this.innerHTML\n }\n}\n\ncustomElements.define(\"alchemy-growl\", Growl)\n","class Icon extends HTMLElement {\n static get observedAttributes() {\n return [\"name\", \"size\", \"icon-style\"]\n }\n\n constructor() {\n super()\n this.spriteUrl = document\n .querySelector('link[rel=\"preload\"][as=\"image\"]')\n .getAttribute(\"href\")\n }\n\n connectedCallback() {\n this.render()\n }\n\n attributeChangedCallback() {\n this.render()\n }\n\n render() {\n const sizeClass = this.size ? ` icon--${this.size}` : \"\"\n this.innerHTML = ``\n }\n\n set name(value) {\n this.setAttribute(\"name\", value)\n }\n\n get iconName() {\n return this.getAttribute(\"name\")\n }\n\n get size() {\n return this.getAttribute(\"size\")\n }\n\n get style() {\n const value = this.getAttribute(\"icon-style\")\n switch (value) {\n case \"none\":\n return \"\"\n case null:\n return \"-line\"\n default:\n return `-${value}`\n }\n }\n}\n\ncustomElements.define(\"alchemy-icon\", Icon)\n","export class IngredientGroup extends HTMLDetailsElement {\n #localStorageKey = \"Alchemy.expanded_ingredient_groups\"\n\n connectedCallback() {\n this.addEventListener(\"toggle\", this)\n\n if (this.isInLocalStorage) {\n this.open = true\n }\n }\n\n disconnectedCallback() {\n this.removeEventListener(\"toggle\", this)\n }\n\n /**\n * Toggle visibility of the ingredient fields in this group\n */\n handleEvent() {\n let expanded_ingredient_groups = this.localStorageItem\n\n if (this.open) {\n this.toggleIcon.name = \"arrow-down-s\"\n if (!this.isInLocalStorage) expanded_ingredient_groups.push(this.id)\n } else {\n this.toggleIcon.name = \"arrow-right-s\"\n expanded_ingredient_groups = expanded_ingredient_groups.filter(\n (value) => value !== this.id\n )\n }\n\n localStorage.setItem(\n this.#localStorageKey,\n JSON.stringify(expanded_ingredient_groups)\n )\n }\n\n get isInLocalStorage() {\n return this.localStorageItem.includes(this.id)\n }\n\n get localStorageItem() {\n const item = localStorage.getItem(this.#localStorageKey)\n\n if (!item) return []\n\n try {\n return JSON.parse(item)\n } catch (error) {\n console.error(error)\n return []\n }\n }\n\n get toggleIcon() {\n return this.querySelector(\"alchemy-icon\")\n }\n}\n\ncustomElements.define(\"alchemy-ingredient-group\", IngredientGroup, {\n extends: \"details\"\n})\n","class LinkButton extends HTMLButtonElement {\n connectedCallback() {\n this.addEventListener(\"click\", this)\n this.classList.add(\"icon_button\")\n // Prevent accidental form submits if this component is wrapped inside a form\n this.setAttribute(\"type\", \"button\")\n this.innerHTML = ''\n }\n\n handleEvent(event) {\n const dialog = new Alchemy.LinkDialog({\n url: this.linkUrl,\n title: this.linkTitle,\n target: this.linkTarget,\n type: this.linkClass\n })\n dialog.open().then((link) => this.setLink(link))\n event.preventDefault()\n }\n\n setLink(link) {\n if (link.url === \"\") {\n this.classList.remove(\"linked\")\n this.dispatchEvent(new CustomEvent(\"alchemy:unlink\", { bubbles: true }))\n } else {\n this.classList.add(\"linked\")\n this.dispatchEvent(\n new CustomEvent(\"alchemy:link\", {\n bubbles: true,\n detail: link\n })\n )\n }\n }\n\n get linkUrl() {\n return this.linkButtons.linkUrlField.value\n }\n\n get linkTitle() {\n return this.linkButtons.linkTitleField.value\n }\n\n get linkTarget() {\n return this.linkButtons.linkTargetField.value\n }\n\n get linkClass() {\n return this.linkButtons.linkClassField.value\n }\n\n get linkButtons() {\n return this.closest(\"alchemy-link-buttons\")\n }\n}\n\ncustomElements.define(\"alchemy-link-button\", LinkButton, { extends: \"button\" })\n","class UnlinkButton extends HTMLButtonElement {\n connectedCallback() {\n this.addEventListener(\"click\", this)\n this.classList.add(\"icon_button\")\n // Prevent accidental form submits if this component is wrapped inside a form\n this.setAttribute(\"type\", \"button\")\n this.linked = this.linked\n this.innerHTML =\n ''\n }\n\n handleEvent(event) {\n if (this.linked) {\n this.linked = false\n this.blur()\n this.dispatchEvent(new CustomEvent(\"alchemy:unlink\", { bubbles: true }))\n }\n event.preventDefault()\n }\n\n set linked(isLinked) {\n if (isLinked) {\n this.classList.replace(\"disabled\", \"linked\")\n this.removeAttribute(\"tabindex\")\n } else {\n this.classList.replace(\"linked\", \"disabled\")\n this.setAttribute(\"tabindex\", \"-1\")\n }\n }\n\n get linked() {\n return this.classList.contains(\"linked\")\n }\n}\n\ncustomElements.define(\"alchemy-unlink-button\", UnlinkButton, {\n extends: \"button\"\n})\n","import \"alchemy_admin/components/link_buttons/link_button\"\nimport \"alchemy_admin/components/link_buttons/unlink_button\"\n\nclass LinkButtons extends HTMLElement {\n connectedCallback() {\n this.addEventListener(\"alchemy:link\", this)\n this.addEventListener(\"alchemy:unlink\", this)\n }\n\n disconnectedCallback() {\n this.removeEventListener(\"alchemy:link\", this)\n this.removeEventListener(\"alchemy:unlink\", this)\n }\n\n handleEvent(event) {\n switch (event.type) {\n case \"alchemy:link\":\n this.setLink(event.detail)\n break\n case \"alchemy:unlink\":\n this.removeLink()\n }\n event.stopPropagation()\n }\n\n setLink(data) {\n this.linkUrlField.value = data.url\n this.linkUrlField.dispatchEvent(new Event(\"change\"))\n this.linkTitleField.value = data.title\n this.linkClassField.value = data.type\n this.linkTargetField.value = data.target\n\n this.unlinkButton.linked = true\n this.setElementDirty()\n }\n\n removeLink() {\n this.linkUrlField.value = \"\"\n this.linkUrlField.dispatchEvent(new Event(\"change\"))\n this.linkTitleField.value = \"\"\n this.linkClassField.value = \"\"\n this.linkTargetField.value = \"\"\n\n this.linkButton.classList.remove(\"linked\")\n this.unlinkButton.linked = false\n\n this.setElementDirty()\n }\n\n setElementDirty() {\n this.elementEditor.setDirty(this)\n }\n\n get linkButton() {\n return this.querySelector('[is=\"alchemy-link-button\"]')\n }\n\n get unlinkButton() {\n return this.querySelector('[is=\"alchemy-unlink-button\"]')\n }\n\n get ingredientEditor() {\n const ingredientId = this.dataset.ingredientId\n return this.parentElement.closest(`[data-ingredient-id='${ingredientId}']`)\n }\n\n get elementEditor() {\n return this.closest(\"alchemy-element-editor\")\n }\n\n get linkUrlField() {\n return this.ingredientEditor.querySelector(\"[data-link-value]\")\n }\n\n get linkTitleField() {\n return this.ingredientEditor.querySelector(\"[data-link-title]\")\n }\n\n get linkTargetField() {\n return this.ingredientEditor.querySelector(\"[data-link-target]\")\n }\n\n get linkClassField() {\n return this.ingredientEditor.querySelector(\"[data-link-class]\")\n }\n}\n\ncustomElements.define(\"alchemy-link-buttons\", LinkButtons)\n","export function formatFileSize(bytes) {\n let exponent = bytes === 0 ? 0 : Math.floor(Math.log(bytes) / Math.log(1024))\n\n // prevent format higher than GB\n if (exponent > 3) {\n exponent = 3\n }\n\n let value = (bytes / Math.pow(1024, exponent)).toFixed(2)\n return value + \" \" + [\"B\", \"kB\", \"MB\", \"GB\"][exponent]\n}\n","import {\n RemoteSelect,\n escapeHtml\n} from \"alchemy_admin/components/remote_select\"\n\nclass NodeSelect extends RemoteSelect {\n _searchQuery(term, page) {\n return {\n filter: {\n name_or_page_name_cont: term,\n ...JSON.parse(this.queryParams)\n },\n page: page\n }\n }\n\n _renderResult(item) {\n return this._renderListEntry(item)\n }\n\n /**\n * html template for each list entry\n * @param {object} node\n * @param {string} term\n * @returns {string}\n * @private\n */\n _renderListEntry(node, term) {\n const ancestors = node.ancestors.map((a) => escapeHtml(a.name))\n const seperator = ``\n\n return `\n
    \n \n
    \n \n ${ancestors.length > 0 ? ancestors.join(seperator) + seperator : \"\"}\n \n \n ${this._hightlightTerm(node.name, term)}\n \n
    \n
    \n ${escapeHtml(node.url || \"\")}\n
    \n
    \n `\n }\n}\n\ncustomElements.define(\"alchemy-node-select\", NodeSelect)\n","import { formatFileSize } from \"alchemy_admin/utils/format\"\nimport { translate } from \"alchemy_admin/i18n\"\nimport { growl } from \"alchemy_admin/growler\"\n\nexport class FileUpload extends HTMLElement {\n // public — used by callers (Uploader, Progress, tests)\n file = null\n request = null\n progressEventLoaded = 0\n progressEventTotal = 0\n\n // private — backing state for getters/setters\n #valid = true\n #value = 0\n #status = undefined\n #errorMessage = \"\"\n\n connectedCallback() {\n this.innerHTML = `\n \n
    \n ${this.file?.name}\n ${this.loadedSize}\n ${this.errorMessage}\n
    \n \n \n \n `\n\n this.querySelector(\"button\").addEventListener(\"click\", () => this.cancel())\n\n if (this.file?.type.includes(\"image\")) {\n const reader = new FileReader()\n reader.readAsDataURL(this.file)\n reader.addEventListener(\"load\", () => {\n const image = new Image()\n image.src = reader.result\n this.prepend(image)\n })\n }\n }\n\n /**\n * Initialize the component with file and request\n * @param {File} file\n * @param {XMLHttpRequest} request\n */\n initialize(file, request) {\n this.file = file\n this.request = request\n this.progressEventTotal = file ? file.size : 0\n this.status = \"in-progress\"\n\n this.#validateFile()\n this.#addRequestEventListener()\n }\n\n /**\n * cancel the upload\n */\n cancel() {\n if (!this.finished) {\n this.status = \"canceled\"\n this.request?.abort()\n this.dispatchCustomEvent(\"FileUpload.Change\")\n }\n }\n\n /**\n * Dispatches a custom event with given name, namespaced under `Alchemy.`.\n * @param {string} name The name of the custom event\n */\n dispatchCustomEvent(name) {\n this.dispatchEvent(new CustomEvent(`Alchemy.${name}`, { bubbles: true }))\n }\n\n /**\n * validate given file with the `Alchemy.uploader_defaults` - configuration\n */\n #validateFile() {\n const config = Alchemy.uploader_defaults\n const maxFileSize = config.file_size_limit * Math.pow(1024, 2) // in Byte\n let errorMessage = undefined\n\n if (this.file?.size > maxFileSize) {\n errorMessage = translate(\"Uploaded bytes exceed file size\")\n }\n\n const allowedFiletypes = this.file?.type.includes(\"image\")\n ? config.allowed_filetypes.alchemy_pictures\n : config.allowed_filetypes.alchemy_attachments\n\n // The allowlist holds filename extensions, so we cannot derive the format\n // from the MIME type: `application/msword` is not `doc` and every Office\n // Open XML type only yields `vnd`.\n const extension = this.file?.name.match(/\\.([^.]+)$/)?.[1].toLowerCase()\n\n const isFileFormatSupported =\n allowedFiletypes.includes(\"*\") ||\n allowedFiletypes.some((type) => type.toLowerCase() === extension)\n\n if (!isFileFormatSupported) {\n errorMessage = translate(\"File type not allowed\")\n }\n\n if (errorMessage) {\n this.valid = false\n this.errorMessage = errorMessage\n }\n }\n\n /**\n * register event listeners to react on request changes\n */\n #addRequestEventListener() {\n // prevent errors if the component will be called without a request - object\n if (!this.request) {\n return\n }\n\n // update the progress bar and currently loaded size information\n this.request.upload.onprogress = (progressEvent) => {\n this.progressEvent = progressEvent\n }\n\n // triggers, when the upload is done\n this.request.onload = () => {\n if (this.request.status < 400) {\n this.status = \"successful\"\n growl(this.responseMessage)\n } else {\n this.status = \"failed\"\n this.errorMessage = this.responseMessage\n }\n this.dispatchCustomEvent(\"FileUpload.Change\")\n }\n\n // catch request errors\n this.request.onerror = () => {\n this.errorMessage = translate(\"An error occurred during the transaction\")\n }\n }\n\n /**\n * @returns {boolean}\n */\n get active() {\n return this.valid && this.status !== \"canceled\"\n }\n\n /**\n * @returns {string}\n */\n get errorMessage() {\n return this.#errorMessage || \"\"\n }\n\n /**\n * @param {string} message\n */\n set errorMessage(message) {\n this.#errorMessage = message\n const errorMessageContainer = this.querySelector(\".error-message\")\n if (errorMessageContainer) {\n errorMessageContainer.textContent = message\n }\n growl(message, \"error\")\n }\n\n /**\n * @returns {boolean}\n */\n get finished() {\n return [\"canceled\", \"successful\", \"failed\"].includes(this.status)\n }\n\n /**\n * format the loaded and total size and present that as a string\n * @returns {string}\n */\n get loadedSize() {\n return `${formatFileSize(this.progressEventLoaded)} / ${formatFileSize(\n this.progressEventTotal\n )}`\n }\n\n /**\n * @returns {HTMLProgressElement|undefined}\n */\n get progressElement() {\n return this.querySelector(\"sl-progress-bar\")\n }\n\n /**\n * @param {ProgressEvent} progressEvent\n */\n set progressEvent(progressEvent) {\n this.progressEventLoaded = progressEvent.loaded\n this.progressEventTotal = progressEvent.total\n\n this.value = Math.round((progressEvent.loaded / progressEvent.total) * 100)\n this.querySelector(\".loaded-size\").textContent = this.loadedSize\n }\n\n /**\n * @returns {string}\n */\n get responseMessage() {\n try {\n const response = JSON.parse(this.request.responseText)\n return response[\"message\"]\n } catch (error) {\n return `${this.request.status}: ${this.request.statusText}`\n }\n }\n\n /**\n * @returns {string}\n */\n get status() {\n return this.#status\n }\n\n /**\n * @param {string} status\n */\n set status(status) {\n this.#status = status\n this.className = status\n\n this.progressElement?.toggleAttribute(\n \"indeterminate\",\n status === \"upload-finished\"\n )\n }\n\n /**\n * @returns {boolean}\n */\n get valid() {\n return this.#valid\n }\n\n /**\n * @param {boolean} isValid\n */\n set valid(isValid) {\n this.#valid = isValid\n this.classList.toggle(\"invalid\", !isValid)\n }\n\n /**\n * get the progress value of the current file\n * @returns {number}\n */\n get value() {\n return this.#value\n }\n\n /**\n * @param {number} value\n */\n set value(value) {\n this.#value = value\n if (this.progressElement) {\n this.progressElement.value = value\n }\n\n if (value === 100) {\n this.status = \"upload-finished\"\n }\n\n this.dispatchCustomEvent(\"FileUpload.Change\")\n }\n}\n\ncustomElements.define(\"alchemy-file-upload\", FileUpload)\n","import { FileUpload } from \"alchemy_admin/components/uploader/file_upload\"\nimport { formatFileSize } from \"alchemy_admin/utils/format\"\nimport { translate } from \"alchemy_admin/i18n\"\n\nconst template = (buttonLabel, fileCount) => `\n \n
    \n \n\n \n \n \n
    \n
    3 ? 3 : fileCount\n }\">
    \n
    \n`\n\nexport class Progress extends HTMLElement {\n // public — accessed by Uploader and tests\n fileCount = 0\n\n // private — backing state and internals\n #fileUploads = []\n #buttonLabel = translate(\"Cancel all uploads\")\n #actionButton = null\n #visible = false\n #handleFileChange = () => this.#updateView()\n\n connectedCallback() {\n this.innerHTML = template(this.#buttonLabel, this.fileCount)\n this.visible = true\n\n this.#actionButton = this.querySelector(\"button\")\n this.#actionButton.addEventListener(\"click\", () => {\n if (this.finished) {\n this.onComplete(this.status)\n } else {\n this.cancel()\n }\n })\n\n this.#fileUploads.forEach((fileUpload) => {\n this.querySelector(\".single-uploads\").append(fileUpload)\n })\n\n this.#updateView()\n this.addEventListener(\"Alchemy.FileUpload.Change\", this.#handleFileChange)\n }\n\n disconnectedCallback() {\n this.removeEventListener(\n \"Alchemy.FileUpload.Change\",\n this.#handleFileChange\n )\n }\n\n /**\n * Initialize the component with file uploads\n * @param {FileUpload[]} fileUploads\n */\n initialize(fileUploads = []) {\n this.#fileUploads = fileUploads\n this.fileCount = fileUploads.length\n }\n\n /**\n * cancel requests in all remaining uploads\n */\n cancel() {\n this.#activeUploads().forEach((upload) => {\n upload.cancel()\n })\n this.#setupCloseButton()\n }\n\n /**\n * a complete hook to allow the uploader to react and trigger an event\n * it would be possible to trigger the event here, but the dispatching would happen\n * in the scope of that component and can't be cached o uploader - component level\n */\n onComplete(_status) {}\n\n /**\n * get all active upload components\n * @returns {FileUpload[]}\n */\n #activeUploads() {\n return this.#fileUploads.filter((upload) => upload.active)\n }\n\n /**\n * replace cancel button to be the close button\n */\n #setupCloseButton() {\n this.#buttonLabel = translate(\"Close\")\n this.#actionButton.ariaLabel = this.#buttonLabel\n this.#actionButton.parentElement.content = this.#buttonLabel // update tooltip content\n }\n\n /**\n * @param {string} field\n * @returns {number}\n */\n #sumFileProgresses(field) {\n return this.#activeUploads().reduce(\n (accumulator, upload) => upload[field] + accumulator,\n 0\n )\n }\n\n /**\n * don't render the whole element new, because it would prevent selecting buttons\n */\n #updateView() {\n const status = this.status\n this.className = status\n\n // update progress bar\n this.progressElement.value = this.totalProgress\n this.progressElement.toggleAttribute(\n \"indeterminate\",\n status === \"upload-finished\"\n )\n\n // show progress in file size and percentage\n this.querySelector(`.overall-progress-value > span`).textContent =\n this.overallProgressValue\n this.querySelector(`.overall-upload-value`).textContent =\n this.overallUploadSize\n\n if (this.finished) {\n this.#setupCloseButton()\n this.onComplete(status)\n } else {\n this.visible = true\n }\n }\n\n /**\n * @returns {boolean}\n */\n get finished() {\n return this.#activeUploads().every((entry) => entry.finished)\n }\n\n /**\n * @returns {string}\n */\n get overallUploadSize() {\n const uploadedFileCount = this.#activeUploads().filter(\n (fileProgress) => fileProgress.value >= 100\n ).length\n const overallProgressValue = `${\n this.totalProgress\n }% (${uploadedFileCount} / ${this.#activeUploads().length})`\n\n return `${formatFileSize(\n this.#sumFileProgresses(\"progressEventLoaded\")\n )} / ${formatFileSize(this.#sumFileProgresses(\"progressEventTotal\"))}`\n }\n\n /**\n * @returns {string}\n */\n get overallProgressValue() {\n const uploadedFileCount = this.#activeUploads().filter(\n (fileProgress) => fileProgress.value >= 100\n ).length\n return `${this.totalProgress}% (${uploadedFileCount} / ${\n this.#activeUploads().length\n })`\n }\n\n /**\n * @returns {HTMLProgressElement|undefined}\n */\n get progressElement() {\n return this.querySelector(\"sl-progress-bar\")\n }\n\n /**\n * get status of file progresses and accumulate the overall status\n * @returns {string}\n */\n get status() {\n const uploadsStatuses = this.#activeUploads().map(\n (upload) => upload.className\n )\n\n // mark as failed, if any upload failed\n if (uploadsStatuses.includes(\"failed\")) {\n return \"failed\"\n }\n\n // no active upload means that every upload was canceled\n if (uploadsStatuses.length === 0) {\n return \"canceled\"\n }\n\n // all uploads are successful or upload-finished or in-progress\n if (uploadsStatuses.every((entry) => entry === uploadsStatuses[0])) {\n return uploadsStatuses[0]\n }\n\n return \"in-progress\"\n }\n\n /**\n * @returns {number}\n */\n get totalProgress() {\n const totalSize = this.#activeUploads().reduce(\n (accumulator, upload) => accumulator + upload.file.size,\n 0\n )\n let totalProgress = Math.ceil(\n this.#activeUploads().reduce((accumulator, upload) => {\n const weight = upload.file.size / totalSize\n return upload.value * weight + accumulator\n }, 0)\n )\n // prevent rounding errors\n if (totalProgress > 100) {\n totalProgress = 100\n }\n return totalProgress\n }\n\n /**\n * @returns {boolean}\n */\n get visible() {\n return this.#visible\n }\n\n /**\n * @param {boolean} visible\n */\n set visible(visible) {\n this.classList.toggle(\"visible\", visible)\n this.#visible = visible\n }\n}\n\ncustomElements.define(\"alchemy-upload-progress\", Progress)\n","/**\n * @typedef {object} PersistedFile\n * @property {string} name\n * @property {number} size\n */\nimport { Progress } from \"alchemy_admin/components/uploader/progress\"\nimport { FileUpload } from \"alchemy_admin/components/uploader/file_upload\"\nimport { translate } from \"alchemy_admin/i18n\"\nimport { getToken } from \"alchemy_admin/utils/ajax\"\n\nexport class Uploader extends HTMLElement {\n #dropzoneElement = null\n #isDraggedOver = false\n\n connectedCallback() {\n this.fileInput.addEventListener(\"change\", this.#onFileInputChange)\n if (this.dropzone) {\n this.#setupDropZone()\n }\n this.addEventListener(\"Alchemy.upload.successful\", this)\n }\n\n disconnectedCallback() {\n this.fileInput?.removeEventListener(\"change\", this.#onFileInputChange)\n if (this.#dropzoneElement) {\n this.#dropzoneElement.removeEventListener(\n \"dragleave\",\n this.#onDropzoneDragleave\n )\n this.#dropzoneElement.removeEventListener(\"drop\", this.#onDropzoneDrop)\n this.#dropzoneElement.removeEventListener(\n \"dragover\",\n this.#onDropzoneDragover\n )\n this.#dropzoneElement = null\n }\n }\n\n handleEvent(evt) {\n switch (evt.type) {\n case \"Alchemy.upload.successful\":\n this.#handleUploadComplete()\n break\n }\n }\n\n #onFileInputChange = (event) => {\n this.uploadFiles(Array.from(event.target.files))\n }\n\n #toggleDropzoneClass = (enabled) => {\n if (this.#isDraggedOver !== enabled) {\n this.#isDraggedOver = enabled\n this.#dropzoneElement.classList.toggle(\"dragover\")\n }\n }\n\n #onDropzoneDragleave = () => this.#toggleDropzoneClass(false)\n\n #onDropzoneDrop = async (event) => {\n event.preventDefault()\n this.#toggleDropzoneClass(false)\n\n const files = [...event.dataTransfer.items].map((item) => item.getAsFile())\n\n this.uploadFiles(files)\n }\n\n #onDropzoneDragover = (event) => {\n event.preventDefault() // dragover has to be disabled to use the custom drop event\n this.#toggleDropzoneClass(true)\n }\n\n #handleUploadComplete() {\n setTimeout(() => {\n const url = this.redirectUrl\n const turboFrame = this.closest(\"turbo-frame\")\n this.uploadProgress.visible = false\n\n if (!url) return\n\n if (turboFrame) {\n turboFrame.setAttribute(\"src\", url)\n turboFrame.reload()\n } else {\n Turbo.visit(url)\n }\n }, 750)\n }\n\n /**\n * add dragover class to indicate, if the file is draggable\n * @private\n */\n #setupDropZone() {\n this.#dropzoneElement = document.querySelector(this.dropzone)\n if (!this.#dropzoneElement) return\n\n this.#dropzoneElement.addEventListener(\n \"dragleave\",\n this.#onDropzoneDragleave\n )\n this.#dropzoneElement.addEventListener(\"drop\", this.#onDropzoneDrop)\n this.#dropzoneElement.addEventListener(\"dragover\", this.#onDropzoneDragover)\n }\n\n /**\n * @param {File[]} files\n */\n uploadFiles(files) {\n // prepare file progress bars and server request\n let fileUploadCount = 0\n\n const fileUploads = files.map((file) => {\n const request = new XMLHttpRequest()\n const fileUpload = new FileUpload()\n fileUpload.initialize(file, request)\n\n if (Alchemy.uploader_defaults.upload_limit - 1 < fileUploadCount) {\n fileUpload.valid = false\n fileUpload.errorMessage = translate(\"Maximum number of files exceeded\")\n } else if (fileUpload.valid) {\n fileUploadCount++\n this.#submitFile(request, file)\n }\n\n return fileUpload\n })\n\n this.#createProgress(fileUploads)\n }\n\n /**\n * @param {XMLHttpRequest} request\n * @param {File} file\n * @private\n */\n #submitFile(request, file) {\n const form = this.querySelector(\"form\")\n const formData = new FormData(form)\n formData.set(this.fileInput.name, file)\n request.open(\"POST\", form.action)\n request.setRequestHeader(\"X-CSRF-Token\", getToken())\n request.setRequestHeader(\"X-Requested-With\", \"XMLHttpRequest\")\n request.setRequestHeader(\"Accept\", \"application/json\")\n request.send(formData)\n }\n\n /**\n * create (and maybe remove the old) progress bar - component\n * @param {FileUpload[]} fileUploads\n * @private\n */\n #createProgress(fileUploads) {\n if (this.uploadProgress) {\n this.uploadProgress.cancel()\n document.body.removeChild(this.uploadProgress)\n }\n this.uploadProgress = new Progress()\n this.uploadProgress.initialize(fileUploads)\n this.uploadProgress.onComplete = (status) => {\n this.dispatchEvent(\n new CustomEvent(`Alchemy.upload.${status}`, { bubbles: true })\n )\n }\n\n document.body.append(this.uploadProgress)\n }\n\n get dropzone() {\n return this.getAttribute(\"dropzone\")\n }\n\n /**\n * @returns {HTMLInputElement}\n */\n get fileInput() {\n return this.querySelector(\"input[type='file']\")\n }\n\n get redirectUrl() {\n return this.getAttribute(\"redirect-url\")\n }\n}\n\ncustomElements.define(\"alchemy-uploader\", Uploader)\n","class Overlay extends HTMLElement {\n connectedCallback() {\n this.innerHTML = `\n \n
    \n ${this.getAttribute(\"text\") ?? \"\"}\n
    \n `\n }\n\n set show(value) {\n this.classList.toggle(\"visible\", value)\n }\n}\n\ncustomElements.define(\"alchemy-overlay\", Overlay)\n","import { patch } from \"alchemy_admin/utils/ajax\"\nimport { growl } from \"alchemy_admin/growler\"\nimport Spinner from \"alchemy_admin/spinner\"\n\nconst BUTTON = \"BUTTON\"\nconst SPAN = \"SPAN\"\n\n/**\n * Custom element for page nodes in the sitemap tree\n * Handles folding/unfolding of page children\n */\nexport class AlchemyPageNode extends HTMLElement {\n connectedCallback() {\n this.pageId = this.getAttribute(\"page-id\")\n this.folded = this.hasAttribute(\"folded\")\n\n this.folderButton?.addEventListener(\"click\", this)\n }\n\n disconnectedCallback() {\n this.folderButton?.removeEventListener(\"click\", this)\n }\n\n async handleEvent(event) {\n if (event.type === \"click\") {\n await this.handleFolderClick(event)\n }\n }\n\n async handleFolderClick(event) {\n event.preventDefault()\n event.stopPropagation()\n\n const folderButton = event.currentTarget\n folderButton.innerHTML = \"\"\n const spinner = new Spinner(\"small\")\n spinner.spin(folderButton)\n\n try {\n await patch(\n Alchemy.routes.fold_admin_page_path(this.pageId),\n null,\n \"text/vnd.turbo-stream.html\"\n )\n\n this.folded = !this.folded\n this.toggleAttribute(\"folded\", this.folded)\n this.toggleChildren()\n this.updateFolderIcon()\n } catch (error) {\n growl(error.message || error, \"error\")\n this.updateFolderIcon()\n } finally {\n spinner.stop()\n }\n }\n\n toggleChildren() {\n const childrenContainer = this.querySelector(\n `#page_${this.pageId}_children`\n )\n if (childrenContainer) {\n childrenContainer.classList.toggle(\"hidden\", this.folded)\n }\n }\n\n updateFolderIcon() {\n if (this.folderButton) {\n const iconName = this.folded ? \"arrow-right-s\" : \"arrow-down-s\"\n this.folderButton.innerHTML = ``\n }\n }\n\n /**\n * Updates the folder button state based on whether the node has children\n * Converts between button and span as needed\n */\n updateFolderButton() {\n const folderElement = this.querySelector(\".page_folder\")\n if (!folderElement) return\n\n const shouldShowButton = this.hasChildren || this.folded\n\n if (shouldShowButton && folderElement.tagName === SPAN) {\n // Convert span to button with icon\n const iconName = this.folded ? \"arrow-right-s\" : \"arrow-down-s\"\n folderElement.outerHTML = ``\n\n // Re-attach event listener to the new button element\n this.folderButton?.addEventListener(\"click\", this)\n } else if (!shouldShowButton && folderElement.tagName === BUTTON) {\n // Convert button to empty span (no children and not folded)\n folderElement.outerHTML = ''\n } else if (shouldShowButton && folderElement.tagName === BUTTON) {\n // Button exists, just update the icon direction\n this.updateFolderIcon()\n }\n }\n\n get hasChildren() {\n const childrenContainer = this.querySelector(\n `#page_${this.pageId}_children`\n )\n if (!childrenContainer) return false\n\n return (\n childrenContainer.querySelectorAll(\":scope > alchemy-page-node\").length >\n 0\n )\n }\n\n get folderButton() {\n return this.querySelector(\"button.page_folder\")\n }\n}\n\ncustomElements.define(\"alchemy-page-node\", AlchemyPageNode)\n","// Handles the page publication date fields\nexport class PagePublicationFields extends HTMLElement {\n connectedCallback() {\n this.publicField?.addEventListener(\"click\", this.#onClick)\n }\n\n disconnectedCallback() {\n this.publicField?.removeEventListener(\"click\", this.#onClick)\n }\n\n #onClick = (evt) => {\n const checkbox = evt.target\n const date = new Date()\n const now = new Date(\n date.getTime() - date.getTimezoneOffset() * 60000\n ).toISOString()\n\n if (checkbox.checked) {\n this.publicationDateFields.classList.remove(\"hidden\")\n this.publicOnPicker.value = now.substring(0, now.indexOf(\"T\") + 6)\n } else {\n this.publicationDateFields.classList.add(\"hidden\")\n this.publicOnPicker.value = \"\"\n }\n this.publicUntilPicker.value = \"\"\n }\n\n get publicField() {\n return this.querySelector(\"#page_public\")\n }\n\n get publicOnPicker() {\n return this.querySelector(\"input#page_public_on\")\n }\n\n get publicUntilPicker() {\n return this.querySelector(\"input#page_public_until\")\n }\n\n get publicationDateFields() {\n return this.querySelector(\".page-publication-date-fields\")\n }\n}\n\ncustomElements.define(\"alchemy-page-publication-fields\", PagePublicationFields)\n","import {\n RemoteSelect,\n escapeHtml\n} from \"alchemy_admin/components/remote_select\"\n\nexport class PageSelect extends RemoteSelect {\n get pageId() {\n return this.selection ? JSON.parse(this.selection)[\"id\"] : undefined\n }\n\n _searchQuery(term, page) {\n return {\n q: {\n name_cont: term,\n ...JSON.parse(this.queryParams)\n },\n page: page\n }\n }\n\n _parseResponse(response) {\n const meta = response.meta\n return {\n results: response.pages,\n more: meta.page * meta.per_page < meta.total_count\n }\n }\n\n /**\n * result which is visible if a page was selected\n * @param {object} page\n * @returns {string}\n * @private\n */\n _renderResult(page) {\n return escapeHtml(page.text || page.name)\n }\n\n /**\n * html template for each list entry\n * @param {object} page\n * @param {string} term\n * @returns {string}\n * @private\n */\n _renderListEntry(page, term) {\n return `\n
    \n
    \n \n ${this._hightlightTerm(page.name, term)}\n ${escapeHtml(page.site.name)}\n
    \n
    \n ${escapeHtml(page.url_path)}\n ${escapeHtml(page.language_code)}\n
    \n
    \n `\n }\n}\n\ncustomElements.define(\"alchemy-page-select\", PageSelect)\n","class PictureDescriptionSelect extends HTMLElement {\n connectedCallback() {\n this.addEventListener(\"change\", this)\n }\n\n disconnectedCallback() {\n this.removeEventListener(\"change\", this)\n }\n\n handleEvent(event) {\n switch (event.type) {\n case \"change\":\n this.onChange()\n break\n }\n }\n\n onChange() {\n const url = new URL(this.getAttribute(\"url\"))\n const select = this.querySelector(\"select\")\n url.searchParams.set(\"language_id\", select.value)\n Turbo.visit(url, { frame: \"picture_descriptions\" })\n }\n}\n\ncustomElements.define(\n \"alchemy-picture-description-select\",\n PictureDescriptionSelect\n)\n","import debounce from \"alchemy_admin/utils/debounce\"\nimport max from \"alchemy_admin/utils/max\"\nimport { get } from \"alchemy_admin/utils/ajax\"\nimport { growl } from \"alchemy_admin/growler\"\n\nconst UPDATE_DELAY = 125\nconst IMAGE_PLACEHOLDER = ''\nconst THUMBNAIL_SIZE = \"160x120\"\n\nexport class PictureEditor extends HTMLElement {\n connectedCallback() {\n this.cropFromField = this.querySelector(\"[data-crop-from]\")\n this.cropSizeField = this.querySelector(\"[data-crop-size]\")\n this.pictureIdField = this.querySelector(\"[data-picture-id]\")\n this.targetSizeField = this.querySelector(\"[data-target-size]\")\n this.imageCropperField = this.querySelector(\"[data-image-cropper]\")\n this.image = this.querySelector(\"img\")\n this.pictureThumbnail = this.querySelector(\"alchemy-picture-thumbnail\")\n this.deleteButton = this.querySelector(\".picture_tool.delete\")\n this.cropLink = this.querySelector(\".crop_link\")\n\n this.targetSize = this.targetSizeField.dataset.targetSize\n this.pictureId = this.pictureIdField.value\n\n // The mutation observer is observing multiple fields that all get updated\n // simultaneously. We only want to update the image once, so we debounce.\n this.update = debounce(() => {\n this.updateImage()\n this.updateCropLink()\n }, UPDATE_DELAY)\n\n this.deleteButton?.addEventListener(\"click\", this.removeImage)\n\n this.observer = new MutationObserver(this.mutationCallback)\n this.observer.observe(this.cropFromField, { attributes: true })\n this.observer.observe(this.cropSizeField, { attributes: true })\n this.observer.observe(this.pictureIdField, { attributes: true })\n }\n\n disconnectedCallback() {\n this.observer.disconnect()\n }\n\n mutationCallback = (mutationsList) => {\n for (const mutation of mutationsList) {\n if (\"pictureId\" in mutation.target.dataset) {\n this.cropFromField.value = \"\"\n this.cropSizeField.value = \"\"\n this.pictureId = mutation.target.value\n }\n this.update()\n }\n }\n\n updateImage() {\n if (!this.pictureId) return\n\n this.pictureThumbnail.loading = true\n get(Alchemy.routes.url_admin_picture_path(this.pictureId), {\n crop: this.imageCropperEnabled,\n crop_from: this.cropFrom,\n crop_size: this.cropSize,\n flatten: true,\n size: THUMBNAIL_SIZE\n })\n .then(({ data }) => {\n this.pictureThumbnail.src = data.url\n this.pictureThumbnail.image.alt = data.alt\n this.pictureThumbnail.image.title = data.title\n this.setElementDirty()\n })\n .catch((error) => {\n console.error(error.message || error)\n growl(error.message || error, \"error\")\n })\n }\n\n removeImage = () => {\n this.pictureThumbnail.innerHTML = IMAGE_PLACEHOLDER\n this.pictureIdField.value = \"\"\n this.image = null\n this.cropLink.classList.add(\"disabled\")\n this.setElementDirty()\n }\n\n setElementDirty() {\n this.closest(\".element-editor\").setDirty(this)\n }\n\n updateCropLink() {\n if (!this.pictureId || !this.imageCropperEnabled) return\n\n this.cropLink.classList.remove(\"disabled\")\n\n if (this.cropLink.href.match(/(picture_id=)\\d+/)) {\n this.cropLink.href = this.cropLink.href.replace(\n /(picture_id=)\\d+/,\n \"$1\" + this.pictureId\n )\n } else {\n this.cropLink.href = this.cropLink.href + `&picture_id=${this.pictureId}`\n }\n }\n\n get cropFrom() {\n if (this.cropFromField.value === \"\") {\n return this.defaultCropFrom.join(\"x\")\n }\n return this.cropFromField.value\n }\n\n get cropSize() {\n if (this.cropSizeField.value === \"\") {\n return this.defaultCropSize.join(\"x\")\n }\n return this.cropSizeField.value\n }\n\n get defaultCropSize() {\n if (!this.imageCropperEnabled) return []\n\n const mask = this.targetSize.split(\"x\").map((n) => parseInt(n))\n const zoom = max(\n mask[0] / this.imageFileWidth,\n mask[1] / this.imageFileHeight\n )\n\n return [Math.round(mask[0] / zoom), Math.round(mask[1] / zoom)]\n }\n\n get defaultCropFrom() {\n if (!this.imageCropperEnabled) return []\n\n const dimensions = this.defaultCropSize\n\n return [\n Math.round((this.imageFileWidth - dimensions[0]) / 2),\n Math.round((this.imageFileHeight - dimensions[1]) / 2)\n ]\n }\n\n get imageFileWidth() {\n return parseInt(this.pictureIdField.dataset.imageFileWidth)\n }\n\n get imageFileHeight() {\n return parseInt(this.pictureIdField.dataset.imageFileHeight)\n }\n\n get imageCropperEnabled() {\n return this.targetSizeField.dataset.imageCropper === \"true\"\n }\n}\n\ncustomElements.define(\"alchemy-picture-editor\", PictureEditor)\n","export default function (func, delay) {\n let timeout\n\n return function (...args) {\n const that = this\n\n clearTimeout(timeout)\n timeout = setTimeout(() => func.apply(that, args), delay)\n }\n}\n","export default function (a, b) {\n return a >= b ? a : b\n}\n","// Shows spinner while loading images and\n// fades the image after its been loaded\n\nimport Spinner from \"alchemy_admin/spinner\"\n\nexport default class PictureThumbnail extends HTMLElement {\n constructor() {\n super()\n\n this.spinner = new Spinner(\"small\")\n }\n\n handleEvent(evt) {\n switch (evt.type) {\n case \"load\":\n this.#onLoaded()\n break\n case \"error\":\n this.#onError(evt)\n break\n default:\n break\n }\n }\n\n connectedCallback() {\n this.classList.add(\"thumbnail_background\")\n if (this.src && !this.image) {\n this.start()\n }\n this.#setImage()\n }\n\n disconnectedCallback() {\n this.image?.removeEventListener(\"load\", this)\n this.image?.removeEventListener(\"error\", this)\n this.stop()\n }\n\n createImage(src = this.src, alt = this.name) {\n this.image = new Image()\n this.image.src = src\n if (alt) {\n this.image.alt = alt\n }\n }\n\n start(src) {\n this.createImage(src)\n this.image.addEventListener(\"load\", this)\n this.image.addEventListener(\"error\", this)\n this.load()\n }\n\n load() {\n if (this.image?.complete) {\n return\n }\n this.setAttribute(\"loading\", \"loading\")\n this.innerHTML = \"\"\n this.spinner.spin(this)\n }\n\n stop() {\n this.classList.remove(\"loading\")\n this.spinner.stop()\n }\n\n #onLoaded() {\n this.spinner.stop()\n this.removeAttribute(\"loading\")\n }\n\n #onError(evt) {\n const message = `Could not load ${this.image.src}`\n const hoist = this.closest(\".ingredient-editor\")\n this.spinner.stop()\n this.innerHTML = `\n \n \n \n `\n console.error(message, evt)\n }\n\n #setImage() {\n if (this.image?.complete) {\n this.replaceChildren(this.image)\n } else if (this.image) {\n this.append(this.image)\n }\n }\n\n set loading(value) {\n value ? this.load() : this.stop()\n }\n\n set src(src) {\n this.start(src)\n this.#setImage()\n }\n\n get name() {\n return this.getAttribute(\"name\")\n }\n\n get src() {\n return this.getAttribute(\"src\")\n }\n}\n\ncustomElements.define(\"alchemy-picture-thumbnail\", PictureThumbnail)\n","class PublishPageButton extends HTMLElement {\n connectedCallback() {\n this.addEventListener(\"submit\", this)\n document.addEventListener(\"alchemy:page-dirty\", this)\n }\n\n disconnectedCallback() {\n this.removeEventListener(\"submit\", this)\n document.removeEventListener(\"alchemy:page-dirty\", this)\n }\n\n handleEvent(event) {\n switch (event.type) {\n case \"alchemy:page-dirty\":\n this.markDirty(event.detail)\n break\n case \"submit\":\n this.button.loading = true\n break\n }\n }\n\n markDirty(detail) {\n this.button.variant = \"primary\"\n this.button.disabled = false\n this.tooltip.content = detail.tooltip\n }\n\n get button() {\n return this.querySelector(\"sl-button\")\n }\n\n get tooltip() {\n return this.querySelector(\"sl-tooltip\")\n }\n}\n\ncustomElements.define(\"alchemy-publish-page-button\", PublishPageButton)\n","/**\n * MicroEvent - to make any js object an event emitter\n *\n * - pure javascript - server compatible, browser compatible\n * - dont rely on the browser doms\n * - super simple - you get it immediatly, no mistery, no magic involved\n *\n * @author Jerome Etienne (https://github.com/jeromeetienne)\n */\n/**\n * Execute callback for each event in space separated list of event names\n *\n */\nfunction forEvents(events, callback) {\n events.split(/\\s+/).forEach((event) => {\n callback(event);\n });\n}\nexport default class MicroEvent {\n constructor() {\n this._events = {};\n }\n on(events, fct) {\n forEvents(events, (event) => {\n const event_array = this._events[event] || [];\n event_array.push(fct);\n this._events[event] = event_array;\n });\n }\n off(events, fct) {\n var n = arguments.length;\n if (n === 0) {\n this._events = {};\n return;\n }\n forEvents(events, (event) => {\n if (n === 1) {\n delete this._events[event];\n return;\n }\n const event_array = this._events[event];\n if (event_array === undefined)\n return;\n event_array.splice(event_array.indexOf(fct), 1);\n this._events[event] = event_array;\n });\n }\n trigger(events, ...args) {\n var self = this;\n forEvents(events, (event) => {\n const event_array = self._events[event];\n if (event_array === undefined)\n return;\n event_array.forEach(fct => {\n fct.apply(self, args);\n });\n });\n }\n}\n;\n//# sourceMappingURL=microevent.js.map","/**\n * Convert array of strings to a regular expression\n *\tex ['ab','a'] => (?:ab|a)\n * \tex ['a','b'] => [ab]\n */\nexport const arrayToPattern = (chars) => {\n chars = chars.filter(Boolean);\n if (chars.length < 2) {\n return chars[0] || '';\n }\n return (maxValueLength(chars) == 1) ? '[' + chars.join('') + ']' : '(?:' + chars.join('|') + ')';\n};\nexport const sequencePattern = (array) => {\n if (!hasDuplicates(array)) {\n return array.join('');\n }\n let pattern = '';\n let prev_char_count = 0;\n const prev_pattern = () => {\n if (prev_char_count > 1) {\n pattern += '{' + prev_char_count + '}';\n }\n };\n array.forEach((char, i) => {\n if (char === array[i - 1]) {\n prev_char_count++;\n return;\n }\n prev_pattern();\n pattern += char;\n prev_char_count = 1;\n });\n prev_pattern();\n return pattern;\n};\n/**\n * Convert array of strings to a regular expression\n *\tex ['ab','a'] => (?:ab|a)\n * \tex ['a','b'] => [ab]\n */\nexport const setToPattern = (chars) => {\n let array = Array.from(chars);\n return arrayToPattern(array);\n};\n/**\n * https://stackoverflow.com/questions/7376598/in-javascript-how-do-i-check-if-an-array-has-duplicate-values\n */\nexport const hasDuplicates = (array) => {\n return (new Set(array)).size !== array.length;\n};\n/**\n * https://stackoverflow.com/questions/63006601/why-does-u-throw-an-invalid-escape-error\n */\nexport const escape_regex = (str) => {\n return (str + '').replace(/([\\$\\(\\)\\*\\+\\.\\?\\[\\]\\^\\{\\|\\}\\\\])/gu, '\\\\$1');\n};\n/**\n * Return the max length of array values\n */\nexport const maxValueLength = (array) => {\n return array.reduce((longest, value) => Math.max(longest, unicodeLength(value)), 0);\n};\nexport const unicodeLength = (str) => {\n return Array.from(str).length;\n};\n//# sourceMappingURL=regex.js.map","/**\n * Get all possible combinations of substrings that add up to the given string\n * https://stackoverflow.com/questions/30169587/find-all-the-combination-of-substrings-that-add-up-to-the-given-string\n */\nexport const allSubstrings = (input) => {\n if (input.length === 1)\n return [[input]];\n let result = [];\n const start = input.substring(1);\n const suba = allSubstrings(start);\n suba.forEach(function (subresult) {\n let tmp = subresult.slice(0);\n tmp[0] = input.charAt(0) + tmp[0];\n result.push(tmp);\n tmp = subresult.slice(0);\n tmp.unshift(input.charAt(0));\n result.push(tmp);\n });\n return result;\n};\n//# sourceMappingURL=strings.js.map","import { setToPattern, arrayToPattern, escape_regex, sequencePattern } from \"./regex.js\";\nimport { allSubstrings } from \"./strings.js\";\nexport const code_points = [[0, 65535]];\nconst accent_pat = '[\\u0300-\\u036F\\u{b7}\\u{2be}\\u{2bc}]';\nexport let unicode_map;\nlet multi_char_reg;\nconst max_char_length = 3;\nconst latin_convert = {};\nconst latin_condensed = {\n '/': '⁄∕',\n '0': '߀',\n \"a\": \"ⱥɐɑ\",\n \"aa\": \"ꜳ\",\n \"ae\": \"æǽǣ\",\n \"ao\": \"ꜵ\",\n \"au\": \"ꜷ\",\n \"av\": \"ꜹꜻ\",\n \"ay\": \"ꜽ\",\n \"b\": \"ƀɓƃ\",\n \"c\": \"ꜿƈȼↄ\",\n \"d\": \"đɗɖᴅƌꮷԁɦ\",\n \"e\": \"ɛǝᴇɇ\",\n \"f\": \"ꝼƒ\",\n \"g\": \"ǥɠꞡᵹꝿɢ\",\n \"h\": \"ħⱨⱶɥ\",\n \"i\": \"ɨı\",\n \"j\": \"ɉȷ\",\n \"k\": \"ƙⱪꝁꝃꝅꞣ\",\n \"l\": \"łƚɫⱡꝉꝇꞁɭ\",\n \"m\": \"ɱɯϻ\",\n \"n\": \"ꞥƞɲꞑᴎлԉ\",\n \"o\": \"øǿɔɵꝋꝍᴑ\",\n \"oe\": \"œ\",\n \"oi\": \"ƣ\",\n \"oo\": \"ꝏ\",\n \"ou\": \"ȣ\",\n \"p\": \"ƥᵽꝑꝓꝕρ\",\n \"q\": \"ꝗꝙɋ\",\n \"r\": \"ɍɽꝛꞧꞃ\",\n \"s\": \"ßȿꞩꞅʂ\",\n \"t\": \"ŧƭʈⱦꞇ\",\n \"th\": \"þ\",\n \"tz\": \"ꜩ\",\n \"u\": \"ʉ\",\n \"v\": \"ʋꝟʌ\",\n \"vy\": \"ꝡ\",\n \"w\": \"ⱳ\",\n \"y\": \"ƴɏỿ\",\n \"z\": \"ƶȥɀⱬꝣ\",\n \"hv\": \"ƕ\"\n};\nfor (let latin in latin_condensed) {\n let unicode = latin_condensed[latin] || '';\n for (let i = 0; i < unicode.length; i++) {\n let char = unicode.substring(i, i + 1);\n latin_convert[char] = latin;\n }\n}\nconst convert_pat = new RegExp(Object.keys(latin_convert).join('|') + '|' + accent_pat, 'gu');\n/**\n * Initialize the unicode_map from the give code point ranges\n */\nexport const initialize = (_code_points) => {\n if (unicode_map !== undefined)\n return;\n unicode_map = generateMap(_code_points || code_points);\n};\n/**\n * Helper method for normalize a string\n * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/normalize\n */\nexport const normalize = (str, form = 'NFKD') => str.normalize(form);\n/**\n * Remove accents without reordering string\n * calling str.normalize('NFKD') on \\u{594}\\u{595}\\u{596} becomes \\u{596}\\u{594}\\u{595}\n * via https://github.com/krisk/Fuse/issues/133#issuecomment-318692703\n */\nexport const asciifold = (str) => {\n return Array.from(str).reduce(\n /**\n * @param {string} result\n * @param {string} char\n */\n (result, char) => {\n return result + _asciifold(char);\n }, '');\n};\nexport const _asciifold = (str) => {\n str = normalize(str)\n .toLowerCase()\n .replace(convert_pat, (/** @type {string} */ char) => {\n return latin_convert[char] || '';\n });\n //return str;\n return normalize(str, 'NFC');\n};\n/**\n * Generate a list of unicode variants from the list of code points\n */\nexport function* generator(code_points) {\n for (const [code_point_min, code_point_max] of code_points) {\n for (let i = code_point_min; i <= code_point_max; i++) {\n let composed = String.fromCharCode(i);\n let folded = asciifold(composed);\n if (folded == composed.toLowerCase()) {\n continue;\n }\n // skip when folded is a string longer than 3 characters long\n // bc the resulting regex patterns will be long\n // eg:\n // folded صلى الله عليه وسلم length 18 code point 65018\n // folded جل جلاله length 8 code point 65019\n if (folded.length > max_char_length) {\n continue;\n }\n if (folded.length == 0) {\n continue;\n }\n yield { folded: folded, composed: composed, code_point: i };\n }\n }\n}\n/**\n * Generate a unicode map from the list of code points\n */\nexport const generateSets = (code_points) => {\n const unicode_sets = {};\n const addMatching = (folded, to_add) => {\n /** @type {Set} */\n const folded_set = unicode_sets[folded] || new Set();\n const patt = new RegExp('^' + setToPattern(folded_set) + '$', 'iu');\n if (to_add.match(patt)) {\n return;\n }\n folded_set.add(escape_regex(to_add));\n unicode_sets[folded] = folded_set;\n };\n for (let value of generator(code_points)) {\n addMatching(value.folded, value.folded);\n addMatching(value.folded, value.composed);\n }\n return unicode_sets;\n};\n/**\n * Generate a unicode map from the list of code points\n * ae => (?:(?:ae|Æ|Ǽ|Ǣ)|(?:A|Ⓐ|A...)(?:E|ɛ|Ⓔ...))\n */\nexport const generateMap = (code_points) => {\n const unicode_sets = generateSets(code_points);\n const unicode_map = {};\n let multi_char = [];\n for (let folded in unicode_sets) {\n let set = unicode_sets[folded];\n if (set) {\n unicode_map[folded] = setToPattern(set);\n }\n if (folded.length > 1) {\n multi_char.push(escape_regex(folded));\n }\n }\n multi_char.sort((a, b) => b.length - a.length);\n const multi_char_patt = arrayToPattern(multi_char);\n multi_char_reg = new RegExp('^' + multi_char_patt, 'u');\n return unicode_map;\n};\n/**\n * Map each element of an array from its folded value to all possible unicode matches\n */\nexport const mapSequence = (strings, min_replacement = 1) => {\n let chars_replaced = 0;\n strings = strings.map((str) => {\n if (unicode_map[str]) {\n chars_replaced += str.length;\n }\n return unicode_map[str] || str;\n });\n if (chars_replaced >= min_replacement) {\n return sequencePattern(strings);\n }\n return '';\n};\n/**\n * Convert a short string and split it into all possible patterns\n * Keep a pattern only if min_replacement is met\n *\n * 'abc'\n * \t\t=> [['abc'],['ab','c'],['a','bc'],['a','b','c']]\n *\t\t=> ['abc-pattern','ab-c-pattern'...]\n */\nexport const substringsToPattern = (str, min_replacement = 1) => {\n min_replacement = Math.max(min_replacement, str.length - 1);\n return arrayToPattern(allSubstrings(str).map((sub_pat) => {\n return mapSequence(sub_pat, min_replacement);\n }));\n};\n/**\n * Convert an array of sequences into a pattern\n * [{start:0,end:3,length:3,substr:'iii'}...] => (?:iii...)\n */\nconst sequencesToPattern = (sequences, all = true) => {\n let min_replacement = sequences.length > 1 ? 1 : 0;\n return arrayToPattern(sequences.map((sequence) => {\n let seq = [];\n const len = all ? sequence.length() : sequence.length() - 1;\n for (let j = 0; j < len; j++) {\n seq.push(substringsToPattern(sequence.substrs[j] || '', min_replacement));\n }\n return sequencePattern(seq);\n }));\n};\n/**\n * Return true if the sequence is already in the sequences\n */\nconst inSequences = (needle_seq, sequences) => {\n for (const seq of sequences) {\n if (seq.start != needle_seq.start || seq.end != needle_seq.end) {\n continue;\n }\n if (seq.substrs.join('') !== needle_seq.substrs.join('')) {\n continue;\n }\n let needle_parts = needle_seq.parts;\n const filter = (part) => {\n for (const needle_part of needle_parts) {\n if (needle_part.start === part.start && needle_part.substr === part.substr) {\n return false;\n }\n if (part.length == 1 || needle_part.length == 1) {\n continue;\n }\n // check for overlapping parts\n // a = ['::=','==']\n // b = ['::','===']\n // a = ['r','sm']\n // b = ['rs','m']\n if (part.start < needle_part.start && part.end > needle_part.start) {\n return true;\n }\n if (needle_part.start < part.start && needle_part.end > part.start) {\n return true;\n }\n }\n return false;\n };\n let filtered = seq.parts.filter(filter);\n if (filtered.length > 0) {\n continue;\n }\n return true;\n }\n return false;\n};\nclass Sequence {\n parts;\n substrs;\n start;\n end;\n constructor() {\n this.parts = [];\n this.substrs = [];\n this.start = 0;\n this.end = 0;\n }\n add(part) {\n if (part) {\n this.parts.push(part);\n this.substrs.push(part.substr);\n this.start = Math.min(part.start, this.start);\n this.end = Math.max(part.end, this.end);\n }\n }\n last() {\n return this.parts[this.parts.length - 1];\n }\n length() {\n return this.parts.length;\n }\n clone(position, last_piece) {\n let clone = new Sequence();\n let parts = JSON.parse(JSON.stringify(this.parts));\n let last_part = parts.pop();\n for (const part of parts) {\n clone.add(part);\n }\n let last_substr = last_piece.substr.substring(0, position - last_part.start);\n let clone_last_len = last_substr.length;\n clone.add({ start: last_part.start, end: last_part.start + clone_last_len, length: clone_last_len, substr: last_substr });\n return clone;\n }\n}\n/**\n * Expand a regular expression pattern to include unicode variants\n * \teg /a/ becomes /aⓐaẚàáâầấẫẩãāăằắẵẳȧǡäǟảåǻǎȁȃạậặḁąⱥɐɑAⒶAÀÁÂẦẤẪẨÃĀĂẰẮẴẲȦǠÄǞẢÅǺǍȀȂẠẬẶḀĄȺⱯ/\n *\n * Issue:\n * ﺊﺋ [ 'ﺊ = \\\\u{fe8a}', 'ﺋ = \\\\u{fe8b}' ]\n *\tbecomes:\tئئ [ 'ي = \\\\u{64a}', 'ٔ = \\\\u{654}', 'ي = \\\\u{64a}', 'ٔ = \\\\u{654}' ]\n *\n *\tİIJ = IIJ = ⅡJ\n *\n * \t1/2/4\n */\nexport const getPattern = (str) => {\n initialize();\n str = asciifold(str);\n let pattern = '';\n let sequences = [new Sequence()];\n for (let i = 0; i < str.length; i++) {\n let substr = str.substring(i);\n let match = substr.match(multi_char_reg);\n const char = str.substring(i, i + 1);\n const match_str = match ? match[0] : null;\n // loop through sequences\n // add either the char or multi_match\n let overlapping = [];\n let added_types = new Set();\n for (const sequence of sequences) {\n const last_piece = sequence.last();\n if (!last_piece || last_piece.length == 1 || last_piece.end <= i) {\n // if we have a multi match\n if (match_str) {\n const len = match_str.length;\n sequence.add({ start: i, end: i + len, length: len, substr: match_str });\n added_types.add('1');\n }\n else {\n sequence.add({ start: i, end: i + 1, length: 1, substr: char });\n added_types.add('2');\n }\n }\n else if (match_str) {\n let clone = sequence.clone(i, last_piece);\n const len = match_str.length;\n clone.add({ start: i, end: i + len, length: len, substr: match_str });\n overlapping.push(clone);\n }\n else {\n // don't add char\n // adding would create invalid patterns: 234 => [2,34,4]\n added_types.add('3');\n }\n }\n // if we have overlapping\n if (overlapping.length > 0) {\n // ['ii','iii'] before ['i','i','iii']\n overlapping = overlapping.sort((a, b) => {\n return a.length() - b.length();\n });\n for (let clone of overlapping) {\n // don't add if we already have an equivalent sequence\n if (inSequences(clone, sequences)) {\n continue;\n }\n sequences.push(clone);\n }\n continue;\n }\n // if we haven't done anything unique\n // clean up the patterns\n // helps keep patterns smaller\n // if str = 'r₨㎧aarss', pattern will be 446 instead of 655\n if (i > 0 && added_types.size == 1 && !added_types.has('3')) {\n pattern += sequencesToPattern(sequences, false);\n let new_seq = new Sequence();\n const old_seq = sequences[0];\n if (old_seq) {\n new_seq.add(old_seq.last());\n }\n sequences = [new_seq];\n }\n }\n pattern += sequencesToPattern(sequences, true);\n return pattern;\n};\nexport { escape_regex };\n//# sourceMappingURL=index.js.map","import { asciifold } from '@orchidjs/unicode-variants';\n/**\n * A property getter resolving dot-notation\n * @param {Object} obj The root object to fetch property on\n * @param {String} name The optionally dotted property name to fetch\n * @return {Object} The resolved property value\n */\nexport const getAttr = (obj, name) => {\n if (!obj)\n return;\n return obj[name];\n};\n/**\n * A property getter resolving dot-notation\n * @param {Object} obj The root object to fetch property on\n * @param {String} name The optionally dotted property name to fetch\n * @return {Object} The resolved property value\n */\nexport const getAttrNesting = (obj, name) => {\n if (!obj)\n return;\n var part, names = name.split(\".\");\n while ((part = names.shift()) && (obj = obj[part]))\n ;\n return obj;\n};\n/**\n * Calculates how close of a match the\n * given value is against a search token.\n *\n */\nexport const scoreValue = (value, token, weight) => {\n var score, pos;\n if (!value)\n return 0;\n value = value + '';\n if (token.regex == null)\n return 0;\n pos = value.search(token.regex);\n if (pos === -1)\n return 0;\n score = token.string.length / value.length;\n if (pos === 0)\n score += 0.5;\n return score * weight;\n};\n/**\n * Cast object property to an array if it exists and has a value\n *\n */\nexport const propToArray = (obj, key) => {\n var value = obj[key];\n if (typeof value == 'function')\n return value;\n if (value && !Array.isArray(value)) {\n obj[key] = [value];\n }\n};\n/**\n * Iterates over arrays and hashes.\n *\n * ```\n * iterate(this.items, function(item, id) {\n * // invoked for each item\n * });\n * ```\n *\n */\nexport const iterate = (object, callback) => {\n if (Array.isArray(object)) {\n object.forEach(callback);\n }\n else {\n for (var key in object) {\n if (object.hasOwnProperty(key)) {\n callback(object[key], key);\n }\n }\n }\n};\nexport const cmp = (a, b) => {\n if (typeof a === 'number' && typeof b === 'number') {\n return a > b ? 1 : (a < b ? -1 : 0);\n }\n a = asciifold(a + '').toLowerCase();\n b = asciifold(b + '').toLowerCase();\n if (a > b)\n return 1;\n if (b > a)\n return -1;\n return 0;\n};\n//# sourceMappingURL=utils.js.map","/**\n * sifter.js\n * Copyright (c) 2013–2020 Brian Reavis & contributors\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\"); you may not use this\n * file except in compliance with the License. You may obtain a copy of the License at:\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software distributed under\n * the License is distributed on an \"AS IS\" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF\n * ANY KIND, either express or implied. See the License for the specific language\n * governing permissions and limitations under the License.\n *\n * @author Brian Reavis \n */\nimport { scoreValue, getAttr, getAttrNesting, propToArray, iterate, cmp } from \"./utils.js\";\nimport { getPattern, escape_regex } from '@orchidjs/unicode-variants';\nclass Sifter {\n items; // []|{};\n settings;\n /**\n * Textually searches arrays and hashes of objects\n * by property (or multiple properties). Designed\n * specifically for autocomplete.\n *\n */\n constructor(items, settings) {\n this.items = items;\n this.settings = settings || { diacritics: true };\n }\n ;\n /**\n * Splits a search string into an array of individual\n * regexps to be used to match results.\n *\n */\n tokenize(query, respect_word_boundaries, weights) {\n if (!query || !query.length)\n return [];\n const tokens = [];\n const words = query.split(/\\s+/);\n var field_regex;\n if (weights) {\n field_regex = new RegExp('^(' + Object.keys(weights).map(escape_regex).join('|') + ')\\:(.*)$');\n }\n words.forEach((word) => {\n let field_match;\n let field = null;\n let regex = null;\n // look for \"field:query\" tokens\n if (field_regex && (field_match = word.match(field_regex))) {\n field = field_match[1];\n word = field_match[2];\n }\n if (word.length > 0) {\n if (this.settings.diacritics) {\n regex = getPattern(word) || null;\n }\n else {\n regex = escape_regex(word);\n }\n if (regex && respect_word_boundaries)\n regex = \"\\\\b\" + regex;\n }\n tokens.push({\n string: word,\n regex: regex ? new RegExp(regex, 'iu') : null,\n field: field,\n });\n });\n return tokens;\n }\n ;\n /**\n * Returns a function to be used to score individual results.\n *\n * Good matches will have a higher score than poor matches.\n * If an item is not a match, 0 will be returned by the function.\n *\n * @returns {T.ScoreFn}\n */\n getScoreFunction(query, options) {\n var search = this.prepareSearch(query, options);\n return this._getScoreFunction(search);\n }\n /**\n * @returns {T.ScoreFn}\n *\n */\n _getScoreFunction(search) {\n const tokens = search.tokens, token_count = tokens.length;\n if (!token_count) {\n return function () { return 0; };\n }\n const fields = search.options.fields, weights = search.weights, field_count = fields.length, getAttrFn = search.getAttrFn;\n if (!field_count) {\n return function () { return 1; };\n }\n /**\n * Calculates the score of an object\n * against the search query.\n *\n */\n const scoreObject = (function () {\n if (field_count === 1) {\n return function (token, data) {\n const field = fields[0].field;\n return scoreValue(getAttrFn(data, field), token, weights[field] || 1);\n };\n }\n return function (token, data) {\n var sum = 0;\n // is the token specific to a field?\n if (token.field) {\n const value = getAttrFn(data, token.field);\n if (!token.regex && value) {\n sum += (1 / field_count);\n }\n else {\n sum += scoreValue(value, token, 1);\n }\n }\n else {\n iterate(weights, (weight, field) => {\n sum += scoreValue(getAttrFn(data, field), token, weight);\n });\n }\n return sum / field_count;\n };\n })();\n if (token_count === 1) {\n return function (data) {\n return scoreObject(tokens[0], data);\n };\n }\n if (search.options.conjunction === 'and') {\n return function (data) {\n var score, sum = 0;\n for (let token of tokens) {\n score = scoreObject(token, data);\n if (score <= 0)\n return 0;\n sum += score;\n }\n return sum / token_count;\n };\n }\n else {\n return function (data) {\n var sum = 0;\n iterate(tokens, (token) => {\n sum += scoreObject(token, data);\n });\n return sum / token_count;\n };\n }\n }\n ;\n /**\n * Returns a function that can be used to compare two\n * results, for sorting purposes. If no sorting should\n * be performed, `null` will be returned.\n *\n * @return function(a,b)\n */\n getSortFunction(query, options) {\n var search = this.prepareSearch(query, options);\n return this._getSortFunction(search);\n }\n _getSortFunction(search) {\n var implicit_score, sort_flds = [];\n const self = this, options = search.options, sort = (!search.query && options.sort_empty) ? options.sort_empty : options.sort;\n if (typeof sort == 'function') {\n return sort.bind(this);\n }\n /**\n * Fetches the specified sort field value\n * from a search result item.\n *\n */\n const get_field = function (name, result) {\n if (name === '$score')\n return result.score;\n return search.getAttrFn(self.items[result.id], name);\n };\n // parse options\n if (sort) {\n for (let s of sort) {\n if (search.query || s.field !== '$score') {\n sort_flds.push(s);\n }\n }\n }\n // the \"$score\" field is implied to be the primary\n // sort field, unless it's manually specified\n if (search.query) {\n implicit_score = true;\n for (let fld of sort_flds) {\n if (fld.field === '$score') {\n implicit_score = false;\n break;\n }\n }\n if (implicit_score) {\n sort_flds.unshift({ field: '$score', direction: 'desc' });\n }\n // without a search.query, all items will have the same score\n }\n else {\n sort_flds = sort_flds.filter((fld) => fld.field !== '$score');\n }\n // build function\n const sort_flds_count = sort_flds.length;\n if (!sort_flds_count) {\n return null;\n }\n return function (a, b) {\n var result, field;\n for (let sort_fld of sort_flds) {\n field = sort_fld.field;\n let multiplier = sort_fld.direction === 'desc' ? -1 : 1;\n result = multiplier * cmp(get_field(field, a), get_field(field, b));\n if (result)\n return result;\n }\n return 0;\n };\n }\n ;\n /**\n * Parses a search query and returns an object\n * with tokens and fields ready to be populated\n * with results.\n *\n */\n prepareSearch(query, optsUser) {\n const weights = {};\n var options = Object.assign({}, optsUser);\n propToArray(options, 'sort');\n propToArray(options, 'sort_empty');\n // convert fields to new format\n if (options.fields) {\n propToArray(options, 'fields');\n const fields = [];\n options.fields.forEach((field) => {\n if (typeof field == 'string') {\n field = { field: field, weight: 1 };\n }\n fields.push(field);\n weights[field.field] = ('weight' in field) ? field.weight : 1;\n });\n options.fields = fields;\n }\n return {\n options: options,\n query: query.toLowerCase().trim(),\n tokens: this.tokenize(query, options.respect_word_boundaries, weights),\n total: 0,\n items: [],\n weights: weights,\n getAttrFn: (options.nesting) ? getAttrNesting : getAttr,\n };\n }\n ;\n /**\n * Searches through all items and returns a sorted array of matches.\n *\n */\n search(query, options) {\n var self = this, score, search;\n search = this.prepareSearch(query, options);\n options = search.options;\n query = search.query;\n // generate result scoring function\n const fn_score = options.score || self._getScoreFunction(search);\n // perform search and sort\n if (query.length) {\n iterate(self.items, (item, id) => {\n score = fn_score(item);\n if (options.filter === false || score > 0) {\n search.items.push({ 'score': score, 'id': id });\n }\n });\n }\n else {\n iterate(self.items, (_, id) => {\n search.items.push({ 'score': 1, 'id': id });\n });\n }\n const fn_sort = self._getSortFunction(search);\n if (fn_sort)\n search.items.sort(fn_sort);\n // apply limits\n search.total = search.items.length;\n if (typeof options.limit === 'number') {\n search.items = search.items.slice(0, options.limit);\n }\n return search;\n }\n ;\n}\nexport { Sifter, scoreValue, getAttr, getAttrNesting, propToArray, iterate, cmp, getPattern };\nexport * from \"./types.js\";\n//# sourceMappingURL=sifter.js.map","/**\n * Converts a scalar to its best string representation\n * for hash keys and HTML attribute values.\n *\n * Transformations:\n * 'str' -> 'str'\n * null -> ''\n * undefined -> ''\n * true -> '1'\n * false -> '0'\n * 0 -> '0'\n * 1 -> '1'\n *\n */\nexport const hash_key = (value) => {\n if (typeof value === 'undefined' || value === null)\n return null;\n return get_hash(value);\n};\nexport const get_hash = (value) => {\n if (typeof value === 'boolean')\n return value ? '1' : '0';\n return value + '';\n};\n/**\n * Escapes a string for use within HTML.\n *\n */\nexport const escape_html = (str) => {\n return (str + '')\n .replace(/&/g, '&')\n .replace(//g, '>')\n .replace(/\"/g, '"');\n};\n/**\n * use setTimeout if timeout > 0\n */\nexport const timeout = (fn, timeout) => {\n if (timeout > 0) {\n return window.setTimeout(fn, timeout);\n }\n fn.call(null);\n return null;\n};\n/**\n * Debounce the user provided load function\n *\n */\nexport const loadDebounce = (fn, delay) => {\n var timeout;\n return function (value, callback) {\n var self = this;\n if (timeout) {\n self.loading = Math.max(self.loading - 1, 0);\n clearTimeout(timeout);\n }\n timeout = setTimeout(function () {\n timeout = null;\n self.loadedSearches[value] = true;\n fn.call(self, value, callback);\n }, delay);\n };\n};\n/**\n * Debounce all fired events types listed in `types`\n * while executing the provided `fn`.\n *\n */\nexport const debounce_events = (self, types, fn) => {\n var type;\n var trigger = self.trigger;\n var event_args = {};\n // override trigger method\n self.trigger = function () {\n var type = arguments[0];\n if (types.indexOf(type) !== -1) {\n event_args[type] = arguments;\n }\n else {\n return trigger.apply(self, arguments);\n }\n };\n // invoke provided function\n fn.apply(self, []);\n self.trigger = trigger;\n // trigger queued events\n for (type of types) {\n if (type in event_args) {\n trigger.apply(self, event_args[type]);\n }\n }\n};\n/**\n * Determines the current selection within a text input control.\n * Returns an object containing:\n * - start\n * - length\n *\n * Note: \"selectionStart, selectionEnd ... apply only to inputs of types text, search, URL, tel and password\"\n * \t- https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/setSelectionRange\n */\nexport const getSelection = (input) => {\n return {\n start: input.selectionStart || 0,\n length: (input.selectionEnd || 0) - (input.selectionStart || 0),\n };\n};\n/**\n * Prevent default\n *\n */\nexport const preventDefault = (evt, stop = false) => {\n if (evt) {\n evt.preventDefault();\n if (stop) {\n evt.stopPropagation();\n }\n }\n};\n/**\n * Add event helper\n *\n */\nexport const addEvent = (target, type, callback, options) => {\n target.addEventListener(type, callback, options);\n};\n/**\n * Return true if the requested key is down\n * Will return false if more than one control character is pressed ( when [ctrl+shift+a] != [ctrl+a] )\n * The current evt may not always set ( eg calling advanceSelection() )\n *\n */\nexport const isKeyDown = (key_name, evt) => {\n if (!evt) {\n return false;\n }\n if (!evt[key_name]) {\n return false;\n }\n var count = (evt.altKey ? 1 : 0) + (evt.ctrlKey ? 1 : 0) + (evt.shiftKey ? 1 : 0) + (evt.metaKey ? 1 : 0);\n if (count === 1) {\n return true;\n }\n return false;\n};\n/**\n * Get the id of an element\n * If the id attribute is not set, set the attribute with the given id\n *\n */\nexport const getId = (el, id) => {\n const existing_id = el.getAttribute('id');\n if (existing_id) {\n return existing_id;\n }\n el.setAttribute('id', id);\n return id;\n};\n/**\n * Returns a string with backslashes added before characters that need to be escaped.\n */\nexport const addSlashes = (str) => {\n return str.replace(/[\\\\\"']/g, '\\\\$&');\n};\n/**\n *\n */\nexport const append = (parent, node) => {\n if (node)\n parent.append(node);\n};\n/**\n * Iterates over arrays and hashes.\n *\n * ```\n * iterate(this.items, function(item, id) {\n * // invoked for each item\n * });\n * ```\n *\n */\nexport const iterate = (object, callback) => {\n if (Array.isArray(object)) {\n object.forEach(callback);\n }\n else {\n for (var key in object) {\n if (object.hasOwnProperty(key)) {\n callback(object[key], key);\n }\n }\n }\n};\n//# sourceMappingURL=utils.js.map","import { iterate } from \"./utils.js\";\n/**\n * Return a dom element from either a dom query string, jQuery object, a dom element or html string\n * https://stackoverflow.com/questions/494143/creating-a-new-dom-element-from-an-html-string-using-built-in-dom-methods-or-pro/35385518#35385518\n *\n * param query should be {}\n */\nexport const getDom = (query) => {\n if (query.jquery) {\n return query[0];\n }\n if (query instanceof HTMLElement) {\n return query;\n }\n if (isHtmlString(query)) {\n var tpl = document.createElement('template');\n tpl.innerHTML = query.trim(); // Never return a text node of whitespace as the result\n return tpl.content.firstChild;\n }\n return document.querySelector(query);\n};\nexport const isHtmlString = (arg) => {\n if (typeof arg === 'string' && arg.indexOf('<') > -1) {\n return true;\n }\n return false;\n};\nexport const escapeQuery = (query) => {\n return query.replace(/['\"\\\\]/g, '\\\\$&');\n};\n/**\n * Dispatch an event\n *\n */\nexport const triggerEvent = (dom_el, event_name) => {\n var event = document.createEvent('HTMLEvents');\n event.initEvent(event_name, true, false);\n dom_el.dispatchEvent(event);\n};\n/**\n * Apply CSS rules to a dom element\n *\n */\nexport const applyCSS = (dom_el, css) => {\n Object.assign(dom_el.style, css);\n};\n/**\n * Add css classes\n *\n */\nexport const addClasses = (elmts, ...classes) => {\n var norm_classes = classesArray(classes);\n elmts = castAsArray(elmts);\n elmts.map(el => {\n norm_classes.map(cls => {\n el.classList.add(cls);\n });\n });\n};\n/**\n * Remove css classes\n *\n */\nexport const removeClasses = (elmts, ...classes) => {\n var norm_classes = classesArray(classes);\n elmts = castAsArray(elmts);\n elmts.map(el => {\n norm_classes.map(cls => {\n el.classList.remove(cls);\n });\n });\n};\n/**\n * Return arguments\n *\n */\nexport const classesArray = (args) => {\n var classes = [];\n iterate(args, (_classes) => {\n if (typeof _classes === 'string') {\n _classes = _classes.trim().split(/[\\t\\n\\f\\r\\s]/);\n }\n if (Array.isArray(_classes)) {\n classes = classes.concat(_classes);\n }\n });\n return classes.filter(Boolean);\n};\n/**\n * Create an array from arg if it's not already an array\n *\n */\nexport const castAsArray = (arg) => {\n if (!Array.isArray(arg)) {\n arg = [arg];\n }\n return arg;\n};\n/**\n * Get the closest node to the evt.target matching the selector\n * Stops at wrapper\n *\n */\nexport const parentMatch = (target, selector, wrapper) => {\n if (wrapper && !wrapper.contains(target)) {\n return;\n }\n while (target && target.matches) {\n if (target.matches(selector)) {\n return target;\n }\n target = target.parentNode;\n }\n};\n/**\n * Get the first or last item from an array\n *\n * > 0 - right (last)\n * <= 0 - left (first)\n *\n */\nexport const getTail = (list, direction = 0) => {\n if (direction > 0) {\n return list[list.length - 1];\n }\n return list[0];\n};\n/**\n * Return true if an object is empty\n *\n */\nexport const isEmptyObject = (obj) => {\n return (Object.keys(obj).length === 0);\n};\n/**\n * Get the index of an element amongst sibling nodes of the same type\n *\n */\nexport const nodeIndex = (el, amongst) => {\n if (!el)\n return -1;\n amongst = amongst || el.nodeName;\n var i = 0;\n while (el = el.previousElementSibling) {\n if (el.matches(amongst)) {\n i++;\n }\n }\n return i;\n};\n/**\n * Set attributes of an element\n *\n */\nexport const setAttr = (el, attrs) => {\n iterate(attrs, (val, attr) => {\n if (val == null) {\n el.removeAttribute(attr);\n }\n else {\n el.setAttribute(attr, '' + val);\n }\n });\n};\n/**\n * Replace a node\n */\nexport const replaceNode = (existing, replacement) => {\n if (existing.parentNode)\n existing.parentNode.replaceChild(replacement, existing);\n};\n//# sourceMappingURL=vanilla.js.map","/**\n * highlight v3 | MIT license | Johann Burkard \n * Highlights arbitrary terms in a node.\n *\n * - Modified by Marshal 2011-6-24 (added regex)\n * - Modified by Brian Reavis 2012-8-27 (cleanup)\n */\nimport { replaceNode } from \"../vanilla.js\";\nexport const highlight = (element, regex) => {\n if (regex === null)\n return;\n // convet string to regex\n if (typeof regex === 'string') {\n if (!regex.length)\n return;\n regex = new RegExp(regex, 'i');\n }\n // Wrap matching part of text node with highlighting , e.g.\n // Soccer -> Soccer for regex = /soc/i\n const highlightText = (node) => {\n var match = node.data.match(regex);\n if (match && node.data.length > 0) {\n var spannode = document.createElement('span');\n spannode.className = 'highlight';\n var middlebit = node.splitText(match.index);\n middlebit.splitText(match[0].length);\n var middleclone = middlebit.cloneNode(true);\n spannode.appendChild(middleclone);\n replaceNode(middlebit, spannode);\n return 1;\n }\n return 0;\n };\n // Recurse element node, looking for child text nodes to highlight, unless element\n // is childless,