From 3f6862f07a092cab263ee5389807dc955d8caf1f Mon Sep 17 00:00:00 2001 From: Matthias Kestenholz Date: Fri, 11 Apr 2025 17:03:53 +0200 Subject: [PATCH] Rewrite everything --- .pre-commit-config.yaml | 2 +- biome.json | 3 + .../static/content_editor/content_editor.css | 1 + .../static/content_editor/content_editor.js | 2374 +++++++++++------ 4 files changed, 1633 insertions(+), 747 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8e226568..7de6e06d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,4 +1,4 @@ -exclude: ".yarn/|yarn.lock|\\.min\\.(css|js)$" +exclude: ".yarn/|yarn.lock|\\.min\\.(css|js)$|material-icons" repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 diff --git a/biome.json b/biome.json index 0cc1ceb5..b2716abd 100644 --- a/biome.json +++ b/biome.json @@ -15,6 +15,9 @@ "a11y": { "noSvgWithoutTitle": "off" }, + "complexity": { + "noForEach": "off" + }, "correctness": { "noUndeclaredVariables": "error", "noUnusedImports": "error", diff --git a/content_editor/static/content_editor/content_editor.css b/content_editor/static/content_editor/content_editor.css index 9758f6a4..79968e39 100644 --- a/content_editor/static/content_editor/content_editor.css +++ b/content_editor/static/content_editor/content_editor.css @@ -434,6 +434,7 @@ h3[draggable] { .card-header > .card-title[draggable]::before { content: "drag_indicator"; + /* biome-ignore lint/a11y/useGenericFontNames: No generic fallback */ font-family: "Material Icons"; font-size: 24px; position: relative; diff --git a/content_editor/static/content_editor/content_editor.js b/content_editor/static/content_editor/content_editor.js index ea441c15..df7aaffb 100644 --- a/content_editor/static/content_editor/content_editor.js +++ b/content_editor/static/content_editor/content_editor.js @@ -1,516 +1,1425 @@ -/* global django,ContentEditor */ -;(() => { - const _contentEditorContext = document.getElementById( - "content-editor-context", - ).textContent +/* global django */ - function qs(sel, ctx = document) { - return ctx.querySelector(sel) - } - function qsa(sel, ctx = document) { - return Array.from(ctx.querySelectorAll(sel)) +const $ = django.jQuery + +/** + * Content Editor - A Django admin plugin for editing content in a flexible, + * structured way with regions, sections, and plugins. + */ +;(() => { + // ========================================================================= + // CONSTANTS AND CONFIGURATION + // ========================================================================= + + const MESSAGES = { + EMPTY: "empty", + EMPTY_INHERITED: "emptyInherited", + NO_REGIONS: "noRegions", + NO_PLUGINS: "noPlugins", + NEW_ITEM: "newItem", + SELECT_MULTIPLE: "selectMultiple", + COLLAPSE_ALL: "collapseAll", + UNCOLLAPSE_ALL: "uncollapseAll", + FOR_DELETION: "forDeletion", + UNKNOWN_REGION: "unknownRegion", } - const safeStorage = (storage, prefix = "ContentEditor:") => { - return { - set(name, value) { - try { - storage.setItem(prefix + name, JSON.stringify(value)) - } finally { - /* empty */ - } - }, - get(name) { - try { - return JSON.parse(storage.getItem(prefix + name)) - } finally { - /* empty */ - } - }, - } + const SELECTORS = { + CONTENT_EDITOR_CONTEXT: "#content-editor-context", + PLUGIN_BUTTONS: ".plugin-buttons", + ORDER_MACHINE: ".order-machine", + ORDER_MACHINE_WRAPPER: ".order-machine-wrapper", + INLINE_RELATED: ".inline-related", + TABS_REGIONS: ".tabs.regions", + INSERT_TARGET: ".order-machine-insert-target", + COLLAPSE_ITEMS: ".collapse-items input", + ORDERING_INPUT: ".order-machine-ordering", + REGION_INPUT: ".order-machine-region", } - const LS = safeStorage(localStorage) - const SS = safeStorage(sessionStorage) - - const prepareContentEditorObject = () => { - Object.assign(ContentEditor, JSON.parse(_contentEditorContext)) - Object.assign(ContentEditor, { - declaredRegions: [...ContentEditor.regions], - pluginsByPrefix: Object.fromEntries( - ContentEditor.plugins.map((plugin) => [plugin.prefix, plugin]), - ), - regionsByKey: Object.fromEntries( - ContentEditor.regions.map((region) => [region.key, region]), - ), - hasSections: ContentEditor.plugins.some((plugin) => plugin.sections), - }) + const STORAGE_KEYS = { + COLLAPSE_ALL: "collapseAll", + EDITOR_STATE: location.pathname, } - django.jQuery(($) => { - window.ContentEditor = { - addContent: function addContent(prefix) { - $(`#${prefix}-group .add-row a`).click() - }, - addPluginButton: function addPluginButton(prefix, iconHTML) { - const plugin = ContentEditor.pluginsByPrefix[prefix] - if (!plugin) return - - const button = document.createElement("a") - button.dataset.pluginPrefix = plugin.prefix - button.className = "plugin-button" - button.title = plugin.title - button.addEventListener("click", (e) => { - e.preventDefault() - ContentEditor.addContent(plugin.prefix) - hidePluginButtons() - }) + const CSS_CLASSES = { + ACTIVE: "active", + COLLAPSED: "collapsed", + SELECTED: "selected", + HIDDEN: "hidden", + INVISIBLE: "content-editor-invisible", + HIDE: "content-editor-hide", + DRAGGABLE: "fs-draggable", + DRAGGING: "fs-dragging", + DRAGOVER: "fs-dragover", + DRAGOVER_AFTER: "fs-dragover--after", + FOR_DELETION: "for-deletion", + LAST_RELATED: "last-related", + EMPTY_FORM: "empty-form", + PLUGIN_BUTTONS_VISIBLE: "plugin-buttons-visible", + ORDER_MACHINE_HIDE_INSERT_TARGETS: "order-machine-hide-insert-targets", + ORDER_MACHINE_READONLY: "order-machine-readonly", + } - const icon = document.createElement("span") - icon.className = "plugin-button-icon" - icon.innerHTML = - iconHTML || 'extension' - button.appendChild(icon) - if (plugin.color) { - icon.style.color = plugin.color + const STORAGE_PREFIX = "ContentEditor:" + + // ========================================================================= + // UTILITY MODULES + // ========================================================================= + + /** + * DOM utility functions + */ + const DOM = { + /** + * Query selector shorthand - returns the first matching element + * @param {string} selector - CSS selector + * @param {Element|Document} context - Element to search within + * @returns {Element|null} - The first matching element or null + */ + qs(selector, context = document) { + return context.querySelector(selector) + }, + + /** + * Query selector all shorthand - returns array of matching elements + * @param {string} selector - CSS selector + * @param {Element|Document} context - Element to search within + * @returns {Element[]} - Array of matching elements + */ + qsa(selector, context = document) { + return Array.from(context.querySelectorAll(selector)) + }, + + /** + * Creates and attaches a DOM element + * @param {string} tag - Element tag name + * @param {Object} attributes - Key-value pairs of attributes + * @param {string|Node|Array} [content] - Text content, child node, or array of children + * @param {Element} [parent] - Optional parent to append to + * @returns {Element} - The created element + */ + createElement(tag, attributes = {}, content = null, parent = null) { + const element = document.createElement(tag) + + // Set attributes + Object.entries(attributes).forEach(([key, value]) => { + if (key === "className") { + element.className = value + } else if (key === "dataset") { + Object.entries(value).forEach(([dataKey, dataValue]) => { + element.dataset[dataKey] = dataValue + }) + } else if (key === "style" && typeof value === "object") { + Object.entries(value).forEach(([prop, val]) => { + element.style[prop] = val + }) + } else { + element.setAttribute(key, value) } + }) - const title = document.createElement("span") - title.className = "plugin-button-title" - title.textContent = plugin.title - button.appendChild(title) + // Set content + if (content !== null) { + if (Array.isArray(content)) { + content.forEach((child) => { + if (child instanceof Node) { + element.appendChild(child) + } else { + element.appendChild(document.createTextNode(String(child))) + } + }) + } else if (content instanceof Node) { + element.appendChild(content) + } else { + element.textContent = String(content) + } + } - const unit = qs(".plugin-buttons") - unit.appendChild(button) + // Append to parent if provided + if (parent instanceof Element) { + parent.appendChild(element) + } - hideNotAllowedPluginButtons([button]) - }, - } + return element + }, + + /** + * Safely injects an HTML template + * @param {string} html - HTML string to parse + * @returns {DocumentFragment} - Document fragment with parsed HTML + */ + createFromHTML(html) { + const template = document.createElement("template") + template.innerHTML = html.trim() + return template.content + }, + + /** + * Get computed rect information about an element + * @param {Element} element - The element to get info for + * @returns {Object} - Element dimensions and position + */ + getRect(element) { + return element.getBoundingClientRect() + }, + + /** + * Tests if an element or its parents match a selector + * @param {Element} element - Element to test + * @param {string} selector - CSS selector to match against + * @returns {Element|null} - Matching element or null + */ + closest(element, selector) { + return element.closest(selector) + }, + + /** + * Toggles multiple classes on an element + * @param {Element} element - Element to modify + * @param {Object} classToggles - Object with className->boolean pairs + */ + toggleClasses(element, classToggles) { + Object.entries(classToggles).forEach(([className, shouldAdd]) => { + element.classList.toggle(className, shouldAdd) + }) + }, + } - prepareContentEditorObject() + /** + * Storage module - handles safe interaction with localStorage and sessionStorage + */ + const Storage = { + /** + * Creates a safe storage wrapper + * @param {Storage} storage - The storage object (localStorage or sessionStorage) + * @param {string} prefix - Key prefix + * @returns {Object} - Storage helper object + */ + createSafeStorage(storage, prefix = STORAGE_PREFIX) { + return { + /** + * Set a value in storage + * @param {string} name - Key name + * @param {*} value - Value to store (will be JSON stringified) + */ + set(name, value) { + try { + storage.setItem(prefix + name, JSON.stringify(value)) + } catch (error) { + console.warn(`Failed to set ${name} in storage:`, error) + } + }, - // Add basic structure. There is always at least one inline group if - // we even have any plugins. - let $anchor = $(".inline-group:first") - if (ContentEditor.plugins.length) { - $anchor = $(`#${ContentEditor.plugins[0].prefix}-group`) - } - $anchor.before( - ` -
-
- -
-
-
-
- -
-
-
-

${ContentEditor.messages.selectMultiple}

- `, - ) - - const addPluginIconsToInlines = () => { - for (const plugin of ContentEditor.plugins) { - const fragment = document.createElement("template") - fragment.innerHTML = - plugin.button || 'extension' - const button = fragment.content.firstElementChild - if (plugin.color) { - button.style.color = plugin.color - } - for (const title of qsa( - `.dynamic-${plugin.prefix} > h3, #${plugin.prefix}-empty > h3`, - )) { - title.insertAdjacentElement("afterbegin", button.cloneNode(true)) - } + /** + * Get a value from storage + * @param {string} name - Key name + * @returns {*} - Parsed value or null if not found + */ + get(name) { + try { + const value = storage.getItem(prefix + name) + return value ? JSON.parse(value) : null + } catch (error) { + console.warn(`Failed to get ${name} from storage:`, error) + return null + } + }, } - } - addPluginIconsToInlines() - - const orderMachineWrapper = $(".order-machine-wrapper") - const pluginButtons = qs(".plugin-buttons") - const orderMachine = $(".order-machine") - const machineEmptyMessage = $('
+
+ +
+
+
+
+ +
+
+
+

${ContentEditor.messages[MESSAGES.SELECT_MULTIPLE]}

+ `) + + // Get key elements + const orderMachineWrapper = $(".order-machine-wrapper") + const pluginButtons = DOM.qs(SELECTORS.PLUGIN_BUTTONS) + const orderMachine = $(SELECTORS.ORDER_MACHINE) + + // Initialize managers + UIManager.initialize(orderMachine, orderMachineWrapper, pluginButtons) + UIManager.addPluginIconsToInlines() + + // Get plugin inline groups + const pluginInlineGroups = $( + ContentEditor.plugins + .map((plugin) => `#${plugin.prefix}-group`) + .join(", "), + ) + + // Initialize plugin inlines + OrderingManager.reorderInlines(pluginInlineGroups, orderMachine) + pluginInlineGroups.hide() + RegionAssignmentManager.assignRegionDataAttribute(orderMachine) + + // Set up region tabs + RegionsManager.createRegionTabs( + DOM.qs(SELECTORS.TABS_REGIONS), + (_region) => { + UIManager.hideInlinesFromOtherRegions() + UIManager.updatePluginButtonsVisibility() + SectionsManager.updateSections( + DOM.qs(SELECTORS.ORDER_MACHINE_WRAPPER), + DOM.qsa(".order-machine .inline-related"), + ) + }, + ) + + // Create all plugin buttons + ContentEditor.createAllPluginButtons(pluginButtons) + + // Set up form integration + FormIntegrationManager.setupFormsetListeners() + + // Add custom styles + StylingManager.addCustomStyles() + + // Set read-only mode if needed + if (!ContentEditor.allowChange) { + $(".order-machine-wrapper").addClass(CSS_CLASSES.ORDER_MACHINE_READONLY) + } + + // Initialize section management if needed + if (PluginsManager.hasSections) { + const debouncedUpdateSections = debounce(() => { + SectionsManager.updateSections( + DOM.qs(SELECTORS.ORDER_MACHINE_WRAPPER), + DOM.qsa(".order-machine .inline-related"), + ) + }, 10) + + // Set up resize observer for sections + try { + const orderMachineWrapper = DOM.qs(SELECTORS.ORDER_MACHINE_WRAPPER) + if (!orderMachineWrapper) { + console.warn( + "Order machine wrapper not found, sections may not update correctly", + ) + return + } + + const resizeObserver = new ResizeObserver(() => { + debouncedUpdateSections() + }) + resizeObserver.observe(orderMachineWrapper) + + // Also update sections when window resizes (fallback and for older browsers) + window.addEventListener("resize", debouncedUpdateSections) + } catch (e) { + console.warn( + "ResizeObserver not supported, falling back to window resize event:", + e, + ) + // Fallback for browsers without ResizeObserver + window.addEventListener("resize", debouncedUpdateSections) + } + } + + // Restore state + setTimeout(StateManager.restoreEditorState, 1) + + // Trigger ready event + $(document).trigger("content-editor:ready") + }) + } - $(document).trigger("content-editor:ready") - }) + // Start initialization + init() })()