app/javascript/panda/cms/controllers/editor_iframe_controller.js in panda-cms-0.7.0 vs app/javascript/panda/cms/controllers/editor_iframe_controller.js in panda-cms-0.7.2

- old
+ new

@@ -1,8 +1,10 @@ import { Controller } from "@hotwired/stimulus" import { PlainTextEditor } from "panda/cms/editor/plain_text_editor" import { EditorJSInitializer } from "panda/cms/editor/editor_js_initializer" +import { EDITOR_JS_RESOURCES, EDITOR_JS_CSS } from "panda/cms/editor/editor_js_config" +import { ResourceLoader } from "panda/cms/editor/resource_loader" export default class extends Controller { static values = { pageId: Number, adminPath: String, @@ -17,10 +19,12 @@ this.editors = [] this.editorsInitialized = { plain: false, rich: false } + this.setupSlideoverHandling() + this.setupEditorInitializationListener() } setupControls() { // Create editor controls if they don't exist if (!parent.document.querySelector('.editor-controls')) { @@ -45,92 +49,78 @@ this.frame.style.display = "" this.frame.style.width = "100%" this.frame.style.height = "100%" this.frame.style.minHeight = "500px" + // Set up iframe stacking context + this.frame.style.position = "relative" + this.frame.style.zIndex = "1" // Lower z-index so it doesn't block UI + // Get CSRF token this.csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || "" // Setup frame load handler this.frame.addEventListener("load", async () => { console.debug("[Panda CMS] Frame loaded") this.frameDocument = this.frame.contentDocument || this.frame.contentWindow.document this.body = this.frameDocument.body this.head = this.frameDocument.head + // Ensure iframe content is properly positioned but doesn't block UI + this.body.style.position = "relative" + this.body.style.zIndex = "1" + + // Add a class to help identify this frame's editors + const frameId = this.frame.id || Math.random().toString(36).substring(7) + this.frame.setAttribute('data-editor-frame-id', frameId) + this.body.setAttribute('data-editor-frame-id', frameId) + + // Prevent default context menu behavior + this.body.addEventListener('contextmenu', (event) => { + // Only prevent default if the target is part of the editor + if (event.target.closest('.codex-editor')) { + event.preventDefault() + } + }) + // Set up error handling for the iframe this.frameDocument.defaultView.onerror = (message, source, lineno, colno, error) => { - // Relay the error to the parent window + // Ignore context menu errors + if (message.includes('anchor.getAttribute') && source.includes('script.js')) { + return true // Prevent error from propagating + } + + // Relay other errors to the parent window const fullMessage = `iFrame Error: ${message} (${source}:${lineno}:${colno})` console.error(fullMessage, error) // Throw the error in the parent context for Cuprite to catch setTimeout(() => { throw new Error(fullMessage) }, 0) - return false // Let the error propagate + return false // Let other errors propagate } // Set up unhandled rejection handling for the iframe this.frameDocument.defaultView.onunhandledrejection = (event) => { + // Ignore context menu related rejections + if (event.reason?.toString().includes('anchor.getAttribute')) { + return + } + const fullMessage = `iFrame Unhandled Promise Rejection: ${event.reason}` console.error(fullMessage) // Throw the error in the parent context for Cuprite to catch setTimeout(() => { throw event.reason }, 0) } - // Ensure frame is visible after load - this.frame.style.display = "" - this.ensureFrameVisibility() - - // Wait for document to be ready - if (this.frameDocument.readyState !== 'complete') { - await new Promise(resolve => { - this.frameDocument.addEventListener('DOMContentLoaded', resolve) - }) - } - - // Load Editor.js resources in the iframe context - try { - const { EDITOR_JS_RESOURCES, EDITOR_JS_CSS } = await import("panda/cms/editor/editor_js_config") - const { ResourceLoader } = await import("panda/cms/editor/resource_loader") - - // First load EditorJS core - const editorCore = EDITOR_JS_RESOURCES[0] - await ResourceLoader.loadScript(this.frameDocument, this.head, editorCore) - - // Then load all tools in parallel - const toolLoads = EDITOR_JS_RESOURCES.slice(1).map(async (resource) => { - await ResourceLoader.loadScript(this.frameDocument, this.head, resource) - }) - - // Load CSS directly - await ResourceLoader.embedCSS(this.frameDocument, this.head, EDITOR_JS_CSS) - - // Wait for all resources to load - await Promise.all(toolLoads) - console.debug("[Panda CMS] Editor resources loaded in iframe") - - // Wait a small amount of time for scripts to initialize - await new Promise(resolve => setTimeout(resolve, 100)) - - // Initialize editors only if we have the body and editable elements - if (this.body && this.body.querySelector('[data-editable-kind]')) { - await this.initializeEditors() - } else { - const error = new Error("[Panda CMS] Frame body or editable elements not found") - console.error(error) - throw error - } - } catch (error) { - console.error("[Panda CMS] Error loading editor resources in iframe:", error) - throw error - } + // Initialize editors after frame is loaded + await this.initializeEditors() }) } ensureFrameVisibility() { // Force frame to be visible @@ -151,11 +141,22 @@ height: this.frame.offsetHeight, visible: this.frame.offsetParent !== null }) } - initializeEditors() { + setupEditorInitializationListener() { + // Listen for the custom event to initialize editors + this.frame.addEventListener("load", () => { + const win = this.frame.contentWindow || this.frame.contentDocument.defaultView + win.addEventListener('panda-cms:initialize-editors', async () => { + console.debug("[Panda CMS] Received initialize-editors event") + await this.initializeEditors() + }) + }) + } + + async initializeEditors() { console.debug("[Panda CMS] Starting editor initialization") // Get all editable elements const plainTextElements = this.body.querySelectorAll('[data-editable-kind="plain_text"], [data-editable-kind="markdown"], [data-editable-kind="html"]') const richTextElements = this.body.querySelectorAll('[data-editable-kind="rich_text"]') @@ -165,15 +166,129 @@ // Always ensure frame is visible this.ensureFrameVisibility() // Initialize editors if they exist if (plainTextElements.length > 0 || richTextElements.length > 0) { - this.initializePlainTextEditors() - this.initializeRichTextEditors() + try { + // Load resources first + await this.loadEditorResources() + + // Initialize editors + await Promise.all([ + this.initializePlainTextEditors(), + this.initializeRichTextEditors() + ]) + + console.debug("[Panda CMS] All editors initialized successfully") + } catch (error) { + console.error("[Panda CMS] Error initializing editors:", error) + throw error + } } } + async loadEditorResources() { + console.debug("[Panda CMS] Loading editor resources in iframe...") + try { + // First load core EditorJS + await ResourceLoader.loadScript(this.frameDocument, this.frameDocument.head, EDITOR_JS_RESOURCES[0]) + + // Wait for EditorJS to be available with increased timeout + let timeout = 10000 // 10 seconds + const start = Date.now() + + while (Date.now() - start < timeout) { + if (this.frameDocument.defaultView.EditorJS) { + console.debug("[Panda CMS] EditorJS core loaded successfully") + break + } + await new Promise(resolve => setTimeout(resolve, 100)) + } + + if (!this.frameDocument.defaultView.EditorJS) { + throw new Error("Timeout waiting for EditorJS core to load") + } + + // Load CSS + await ResourceLoader.embedCSS(this.frameDocument, this.frameDocument.head, EDITOR_JS_CSS) + + // Then load all tools sequentially + for (const resource of EDITOR_JS_RESOURCES.slice(1)) { + await ResourceLoader.loadScript(this.frameDocument, this.frameDocument.head, resource) + } + + console.debug("[Panda CMS] All editor resources loaded successfully") + } catch (error) { + console.error("[Panda CMS] Error loading editor resources:", error) + throw error + } + } + + waitForEditorJS() { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Timeout waiting for EditorJS to load")) + }, 10000) + + const check = () => { + if (this.frameDocument.defaultView.EditorJS) { + clearTimeout(timeout) + resolve() + } else { + setTimeout(check, 100) + } + } + check() + }) + } + + waitForTool(toolName) { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error(`Timeout waiting for ${toolName} to load`)) + }, 10000) + + // Map tool names to their expected global class names + const toolClassMap = { + 'paragraph': 'Paragraph', + 'header': 'Header', + 'nested-list': 'NestedList', + 'quote': 'Quote', + 'simple-image': 'SimpleImage', + 'table': 'Table', + 'embed': 'Embed' + } + + const check = () => { + // Get the expected class name for this tool + const expectedClassName = toolClassMap[toolName] || toolName + const toolClass = this.frameDocument.defaultView[expectedClassName] + + console.debug(`[Panda CMS] Checking for tool ${toolName} -> ${expectedClassName}:`, { + toolFound: !!toolClass, + availableClasses: Object.keys(this.frameDocument.defaultView).filter(key => + key.includes('Header') || + key.includes('Paragraph') || + key.includes('List') || + key.includes('Quote') || + key.includes('Image') || + key.includes('Table') || + key.includes('Embed') + ) + }) + + if (toolClass) { + clearTimeout(timeout) + resolve() + } else { + setTimeout(check, 100) + } + } + check() + }) + } + initializePlainTextEditors() { this.editorsInitialized.plain = false const plainTextElements = this.body.querySelectorAll('[data-editable-kind="plain_text"], [data-editable-kind="markdown"], [data-editable-kind="html"]') console.debug(`[Panda CMS] Found ${plainTextElements.length} plain text elements`) @@ -194,98 +309,209 @@ this.editorsInitialized.rich = false const richTextElements = this.body.querySelectorAll('[data-editable-kind="rich_text"]') console.debug(`[Panda CMS] Found ${richTextElements.length} rich text elements`) if (richTextElements.length > 0) { - // Verify Editor.js is available in the iframe context - if (!this.frameDocument.defaultView.EditorJS) { - const error = new Error("Editor.js not loaded in iframe context") - console.error("[Panda CMS]", error) - throw error // This will bubble up and fail the test - } + try { + // Verify Editor.js is available in the iframe context + if (!this.frameDocument.defaultView.EditorJS) { + const error = new Error("Editor.js not loaded in iframe context") + console.error("[Panda CMS]", error) + throw error + } - const initializer = new EditorJSInitializer(this.frameDocument, true) + const initializer = new EditorJSInitializer(this.frameDocument, true) - // Don't wrap in try/catch to let errors bubble up - const editors = await Promise.all( - Array.from(richTextElements).map(async element => { - // Create holder element before initialization - const holderId = `editor-${Math.random().toString(36).substr(2, 9)}` - const holderElement = this.frameDocument.createElement('div') - holderElement.id = holderId - holderElement.className = 'editor-js-holder codex-editor' - element.appendChild(holderElement) + // Initialize each editor sequentially to avoid race conditions + const editors = [] + for (const element of richTextElements) { + try { + // Skip already initialized editors + if (element.dataset.editableInitialized === 'true' && element.querySelector('.codex-editor')) { + console.debug('[Panda CMS] Editor already initialized:', element.id) + continue + } - // Wait for the holder element to be in the DOM - await new Promise(resolve => setTimeout(resolve, 0)) + console.debug('[Panda CMS] Initializing editor for element:', { + id: element.id, + kind: element.getAttribute('data-editable-kind'), + blockContentId: element.getAttribute('data-editable-block-content-id') + }) - // Verify the holder element exists - const verifyHolder = this.frameDocument.getElementById(holderId) - if (!verifyHolder) { - const error = new Error(`Failed to create editor holder element ${holderId}`) - console.error("[Panda CMS]", error) - throw error // This will bubble up and fail the test - } + // Create holder element before initialization + const holderId = `editor-${Math.random().toString(36).substr(2, 9)}` + const holderElement = this.frameDocument.createElement('div') + holderElement.id = holderId + holderElement.className = 'editor-js-holder codex-editor' - console.debug(`[Panda CMS] Created editor holder: ${holderId}`, { - exists: !!verifyHolder, - parent: element.id || 'no-id', - editorJSAvailable: !!this.frameDocument.defaultView.EditorJS - }) + // Clear any existing content + element.textContent = '' + element.appendChild(holderElement) - // Initialize editor with empty data - const editor = await initializer.initialize(holderElement, {}, holderId) + // Get previous data from the data attribute if available + let previousData = { blocks: [] } + const previousDataAttr = element.getAttribute('data-editable-previous-data') + if (previousDataAttr) { + try { + let parsedData + try { + // First try to parse as base64 + const decodedData = atob(previousDataAttr) + console.debug('[Panda CMS] Decoded base64 data:', decodedData) + parsedData = JSON.parse(decodedData) + } catch (e) { + // If base64 fails, try direct JSON parse + console.debug('[Panda CMS] Trying direct JSON parse') + parsedData = JSON.parse(previousDataAttr) + } - // Set up save handler for this editor - const saveButton = parent.document.getElementById('saveEditableButton') - if (saveButton) { - saveButton.addEventListener('click', async () => { - const outputData = await editor.save() - outputData.source = "editorJS" + // Check if we have double-encoded data + if (parsedData.blocks?.length === 1 && + parsedData.blocks[0]?.type === 'paragraph' && + parsedData.blocks[0]?.data?.text) { + try { + // Try to parse the inner JSON + const innerData = JSON.parse(parsedData.blocks[0].data.text) + if (innerData.blocks) { + console.debug('[Panda CMS] Found double-encoded data, using inner content:', innerData) + parsedData = innerData + } + } catch (e) { + // If parsing fails, use the outer data + console.debug('[Panda CMS] Not double-encoded data, using as is') + } + } - const pageId = element.getAttribute("data-editable-page-id") - const blockContentId = element.getAttribute("data-editable-block-content-id") + if (parsedData && typeof parsedData === 'object' && Array.isArray(parsedData.blocks)) { + previousData = parsedData + console.debug('[Panda CMS] Using previous data:', previousData) + } else { + console.warn('[Panda CMS] Invalid data format:', parsedData) + } + } catch (error) { + console.error("[Panda CMS] Error parsing previous data:", error) + // If we can't parse the data, try to use it as plain text + previousData = { + time: Date.now(), + blocks: [ + { + type: "paragraph", + data: { + text: element.textContent || "" + } + } + ], + version: "2.28.2" + } + } + } - const response = await fetch(`${this.adminPathValue}/pages/${pageId}/block_contents/${blockContentId}`, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - "X-CSRF-Token": this.csrfToken - }, - body: JSON.stringify({ content: outputData }) - }) + // Initialize editor with retry logic + let editor = null + let retryCount = 0 + const maxRetries = 3 - if (!response.ok) { - const error = new Error('Save failed') - console.error("[Panda CMS]", error) - throw error + while (!editor && retryCount < maxRetries) { + try { + console.debug(`[Panda CMS] Editor initialization attempt ${retryCount + 1}`) + editor = await initializer.initialize(holderElement, previousData, holderId) + console.debug('[Panda CMS] Editor initialized successfully:', editor) + + // Set up autosave if enabled + if (this.autosaveValue) { + editor.isReady.then(() => { + editor.save().then((outputData) => { + const jsonString = JSON.stringify(outputData) + element.dataset.editablePreviousData = btoa(jsonString) + element.dataset.editableContent = jsonString + element.dataset.editableInitialized = 'true' + }) + }) + } + + break + } catch (error) { + console.warn(`[Panda CMS] Editor initialization attempt ${retryCount + 1} failed:`, error) + retryCount++ + if (retryCount === maxRetries) { + throw error + } + // Wait before retrying + await new Promise(resolve => setTimeout(resolve, 1000)) } + } - this.handleSuccess() - }) - } else { - console.warn("[Panda CMS] Save button not found") + // Set up save handler for this editor + const saveButton = parent.document.getElementById('saveEditableButton') + if (saveButton) { + saveButton.addEventListener('click', async () => { + try { + const outputData = await editor.save() + console.debug('[Panda CMS] Editor save data:', outputData) + + const pageId = element.getAttribute("data-editable-page-id") + const blockContentId = element.getAttribute("data-editable-block-content-id") + + const response = await fetch(`${this.adminPathValue}/pages/${pageId}/block_contents/${blockContentId}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": this.csrfToken + }, + body: JSON.stringify({ content: outputData }) + }) + + if (!response.ok) { + throw new Error('Save failed') + } + + // Update the data attributes with the new content + const jsonString = JSON.stringify(outputData) + element.dataset.editablePreviousData = btoa(jsonString) + element.dataset.editableContent = jsonString + element.dataset.editableInitialized = 'true' + + this.handleSuccess() + } catch (error) { + console.error("[Panda CMS] Save error:", error) + this.handleError(error) + } + }) + } else { + console.warn("[Panda CMS] Save button not found") + } + + if (editor) { + editors.push(editor) + } + } catch (error) { + console.error("[Panda CMS] Editor initialization error:", error) + throw error } + } - return editor - }) - ) + // Filter out any null editors and add the valid ones + const validEditors = editors.filter(editor => editor !== null) + this.editors.push(...validEditors) - // Filter out any null editors and add the valid ones - const validEditors = editors.filter(editor => editor !== null) - this.editors.push(...validEditors) + // If we didn't get any valid editors, that's an error + if (validEditors.length === 0 && richTextElements.length > 0) { + const error = new Error("No editors were successfully initialized") + console.error("[Panda CMS]", error) + throw error + } - // If we didn't get any valid editors, that's an error - if (validEditors.length === 0) { - const error = new Error("No editors were successfully initialized") - console.error("[Panda CMS]", error) + this.editorsInitialized.rich = true + this.checkAllEditorsInitialized() + } catch (error) { + console.error("[Panda CMS] Rich text editor initialization failed:", error) throw error } + } else { + this.editorsInitialized.rich = true + this.checkAllEditorsInitialized() } - - this.editorsInitialized.rich = true - this.checkAllEditorsInitialized() } checkAllEditorsInitialized() { console.log("[Panda CMS] Editor initialization status:", this.editorsInitialized) @@ -314,7 +540,46 @@ successMessage.classList.remove("hidden") setTimeout(() => { successMessage.classList.add("hidden") }, 3000) } + } + + setupSlideoverHandling() { + // Watch for slideover toggle + const slideoverToggle = document.getElementById('slideover-toggle') + const slideover = document.getElementById('slideover') + + if (slideoverToggle && slideover) { + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.attributeName === 'class') { + const isVisible = !slideover.classList.contains('hidden') + this.adjustFrameZIndex(isVisible) + } + }) + }) + + observer.observe(slideover, { attributes: true }) + + // Initial state + this.adjustFrameZIndex(!slideover.classList.contains('hidden')) + } + } + + adjustFrameZIndex(slideoverVisible) { + if (slideoverVisible) { + // Lower z-index when slideover is visible + this.frame.style.zIndex = "0" + if (this.body) this.body.style.zIndex = "0" + } else { + // Restore z-index when slideover is hidden + this.frame.style.zIndex = "1" + if (this.body) this.body.style.zIndex = "1" + } + console.debug("[Panda CMS] Adjusted frame z-index:", { + slideoverVisible, + frameZIndex: this.frame.style.zIndex, + bodyZIndex: this.body?.style.zIndex + }) } }