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
+ })
}
}