panda-cms 0.7.0 → 0.7.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/panda.cms.css +0 -50
  3. data/app/components/panda/cms/admin/table_component.html.erb +4 -1
  4. data/app/components/panda/cms/code_component.rb +2 -1
  5. data/app/components/panda/cms/rich_text_component.html.erb +86 -2
  6. data/app/components/panda/cms/rich_text_component.rb +131 -20
  7. data/app/controllers/panda/cms/admin/block_contents_controller.rb +18 -7
  8. data/app/controllers/panda/cms/admin/files_controller.rb +22 -12
  9. data/app/controllers/panda/cms/admin/posts_controller.rb +33 -11
  10. data/app/controllers/panda/cms/pages_controller.rb +29 -0
  11. data/app/controllers/panda/cms/posts_controller.rb +26 -4
  12. data/app/helpers/panda/cms/admin/posts_helper.rb +23 -32
  13. data/app/helpers/panda/cms/posts_helper.rb +32 -0
  14. data/app/javascript/panda/cms/controllers/dashboard_controller.js +0 -1
  15. data/app/javascript/panda/cms/controllers/editor_form_controller.js +134 -11
  16. data/app/javascript/panda/cms/controllers/editor_iframe_controller.js +395 -130
  17. data/app/javascript/panda/cms/controllers/slug_controller.js +33 -43
  18. data/app/javascript/panda/cms/editor/editor_js_config.js +202 -73
  19. data/app/javascript/panda/cms/editor/editor_js_initializer.js +243 -194
  20. data/app/javascript/panda/cms/editor/plain_text_editor.js +1 -1
  21. data/app/javascript/panda/cms/editor/resource_loader.js +89 -0
  22. data/app/javascript/panda/cms/editor/rich_text_editor.js +162 -0
  23. data/app/models/panda/cms/page.rb +18 -0
  24. data/app/models/panda/cms/post.rb +61 -3
  25. data/app/models/panda/cms/redirect.rb +2 -2
  26. data/app/views/panda/cms/admin/posts/_form.html.erb +15 -4
  27. data/app/views/panda/cms/admin/posts/index.html.erb +5 -3
  28. data/config/routes.rb +34 -6
  29. data/db/migrate/20250106223303_add_author_id_to_panda_cms_posts.rb +5 -0
  30. data/lib/panda/cms/editor_js_content.rb +14 -1
  31. data/lib/panda/cms/engine.rb +4 -0
  32. data/lib/panda-cms/version.rb +1 -1
  33. metadata +5 -2
@@ -51,235 +51,284 @@ export class EditorJSInitializer {
51
51
  const editorCore = EDITOR_JS_RESOURCES[0]
52
52
  await ResourceLoader.loadScript(this.document, this.document.head, editorCore)
53
53
 
54
- // Then load all tools in parallel
55
- const toolLoads = EDITOR_JS_RESOURCES.slice(1).map(async (resource) => {
56
- await ResourceLoader.loadScript(this.document, this.document.head, resource)
57
- })
54
+ // Wait for EditorJS to be available
55
+ await this.waitForEditorJS()
58
56
 
59
57
  // Load CSS directly
60
58
  await ResourceLoader.embedCSS(this.document, this.document.head, EDITOR_JS_CSS)
61
59
 
62
- // Wait for all resources to load
63
- await Promise.all(toolLoads)
60
+ // Then load all tools sequentially to ensure proper initialization order
61
+ for (const resource of EDITOR_JS_RESOURCES.slice(1)) {
62
+ try {
63
+ await ResourceLoader.loadScript(this.document, this.document.head, resource)
64
+ // Extract tool name from resource URL
65
+ const toolName = resource.split('/').pop().split('@')[0]
66
+ // Wait for tool to be initialized
67
+ const toolClass = await this.waitForTool(toolName)
68
+
69
+ // If this is the nested-list tool, also make it available as 'list'
70
+ if (toolName === 'nested-list') {
71
+ const win = this.document.defaultView || window
72
+ win.List = toolClass
73
+ }
64
74
 
65
- // Wait for EditorJS to be available
66
- await this.waitForEditorJS()
75
+ console.debug(`[Panda CMS] Successfully loaded tool: ${toolName}`)
76
+ } catch (error) {
77
+ console.error(`[Panda CMS] Failed to load tool: ${resource}`, error)
78
+ throw error
79
+ }
80
+ }
81
+
82
+ console.debug('[Panda CMS] All tools successfully loaded and verified')
67
83
  } catch (error) {
84
+ console.error('[Panda CMS] Error loading Editor.js resources:', error)
68
85
  throw error
69
86
  }
70
87
  }
71
88
 
72
- async initializeEditor(element, initialData = {}, editorId = null) {
73
- // Generate a consistent holder ID if not provided
74
- const holderId = editorId || `editor-${element.id || Math.random().toString(36).substr(2, 9)}`
75
-
76
- // Create or find the holder element in the correct document context
77
- let holderElement = this.document.getElementById(holderId)
78
- if (!holderElement) {
79
- // Create the holder element in the correct document context
80
- holderElement = this.document.createElement('div')
81
- holderElement.id = holderId
82
- holderElement.className = 'editor-js-holder codex-editor'
83
-
84
- // Append to the element and force a reflow
85
- element.appendChild(holderElement)
86
- void holderElement.offsetHeight // Force a reflow
89
+ async waitForEditorJS(timeout = 10000) {
90
+ console.debug('[Panda CMS] Waiting for EditorJS core...')
91
+ const start = Date.now()
92
+ while (Date.now() - start < timeout) {
93
+ if (typeof this.document.defaultView.EditorJS === 'function') {
94
+ console.debug('[Panda CMS] EditorJS core is ready')
95
+ return
96
+ }
97
+ await new Promise(resolve => setTimeout(resolve, 100))
87
98
  }
99
+ throw new Error('[Panda CMS] Timeout waiting for EditorJS')
100
+ }
88
101
 
89
- // Verify the holder element exists in the correct document context
90
- const verifyHolder = this.document.getElementById(holderId)
91
- if (!verifyHolder) {
92
- throw new Error(`Failed to create editor holder element ${holderId}`)
102
+ /**
103
+ * Wait for a specific tool to be available in window context
104
+ */
105
+ async waitForTool(toolName, timeout = 10000) {
106
+ if (!toolName) {
107
+ console.error('[Panda CMS] Invalid tool name provided')
108
+ return null
93
109
  }
94
110
 
95
- // Clear any existing content in the holder
96
- holderElement.innerHTML = ''
97
-
98
- // Add source to initial data
99
- if (initialData && !initialData.source) {
100
- initialData.source = "editorJS"
111
+ // Clean up tool name to handle npm package format
112
+ const cleanToolName = toolName.split('/').pop().replace('@', '')
113
+
114
+ const toolMapping = {
115
+ 'paragraph': 'Paragraph',
116
+ 'header': 'Header',
117
+ 'nested-list': 'NestedList',
118
+ 'list': 'NestedList',
119
+ 'quote': 'Quote',
120
+ 'simple-image': 'SimpleImage',
121
+ 'table': 'Table',
122
+ 'embed': 'Embed'
101
123
  }
102
124
 
103
- // Get the base config but pass our document context
104
- const config = getEditorConfig(holderId, initialData, this.document)
125
+ const globalToolName = toolMapping[cleanToolName] || cleanToolName
126
+ const start = Date.now()
105
127
 
106
- // Override specific settings for iframe context
107
- const editorConfig = {
108
- ...config,
109
- holder: holderElement, // Use element reference instead of ID
110
- minHeight: 1, // Prevent auto-height issues in iframe
111
- autofocus: false, // Prevent focus issues
112
- logLevel: 'ERROR', // Only show errors
113
- tools: {
114
- ...config.tools,
115
- // Ensure tools use the correct window context
116
- paragraph: { ...config.tools.paragraph, class: this.document.defaultView.Paragraph },
117
- header: { ...config.tools.header, class: this.document.defaultView.Header },
118
- list: { ...config.tools.list, class: this.document.defaultView.NestedList },
119
- quote: { ...config.tools.quote, class: this.document.defaultView.Quote },
120
- table: { ...config.tools.table, class: this.document.defaultView.Table },
121
- image: { ...config.tools.image, class: this.document.defaultView.SimpleImage },
122
- embed: { ...config.tools.embed, class: this.document.defaultView.Embed }
128
+ while (Date.now() - start < timeout) {
129
+ const win = this.document.defaultView || window
130
+ if (win[globalToolName] && typeof win[globalToolName] === 'function') {
131
+ // If this is the NestedList tool, make it available as both list and nested-list
132
+ if (globalToolName === 'NestedList') {
133
+ win.List = win[globalToolName]
134
+ }
135
+ console.debug(`[Panda CMS] Successfully loaded tool: ${globalToolName}`)
136
+ return win[globalToolName]
123
137
  }
138
+ await new Promise(resolve => setTimeout(resolve, 100))
124
139
  }
140
+ throw new Error(`[Panda CMS] Timeout waiting for tool: ${cleanToolName} (${globalToolName})`)
141
+ }
125
142
 
126
- // Create editor instance directly
127
- const editor = new this.document.defaultView.EditorJS({
128
- ...editorConfig,
129
- onReady: () => {
130
- // Store the editor instance globally for testing
131
- if (this.withinIFrame) {
132
- this.document.defaultView.editor = editor
133
- } else {
134
- window.editor = editor
135
- }
143
+ async initializeEditor(element, initialData = {}, editorId = null) {
144
+ try {
145
+ // Wait for EditorJS core to be available with increased timeout
146
+ await this.waitForEditorJS(15000)
136
147
 
137
- // Mark editor as ready
138
- editor.isReady = true
148
+ // Get the window context (either iframe or parent)
149
+ const win = this.document.defaultView || window
139
150
 
140
- // Force redraw of toolbar and blocks
141
- setTimeout(async () => {
142
- try {
143
- const toolbar = holderElement.querySelector('.ce-toolbar')
144
- const blockWrapper = holderElement.querySelector('.ce-block')
151
+ // Create a unique ID for this editor instance if not provided
152
+ const uniqueId = editorId || `editor-${Math.random().toString(36).substring(2)}`
145
153
 
146
- if (!toolbar || !blockWrapper) {
147
- // Clear and insert a new block to force UI update
148
- await editor.blocks.clear()
149
- await editor.blocks.insert('paragraph')
154
+ // Check if editor already exists
155
+ const existingEditor = element.querySelector('.codex-editor')
156
+ if (existingEditor) {
157
+ console.debug('[Panda CMS] Editor already exists, cleaning up...')
158
+ existingEditor.remove()
159
+ }
150
160
 
151
- // Force a redraw by toggling display
152
- holderElement.style.display = 'none'
153
- void holderElement.offsetHeight
154
- holderElement.style.display = ''
161
+ // Create a holder div for the editor
162
+ const holder = this.document.createElement("div")
163
+ holder.id = uniqueId
164
+ holder.classList.add("editor-js-holder")
165
+ element.appendChild(holder)
166
+
167
+ // Process initial data to handle list items and other content
168
+ let processedData = initialData
169
+ if (initialData.blocks) {
170
+ processedData = {
171
+ ...initialData,
172
+ blocks: initialData.blocks.map(block => {
173
+ if (block.type === 'list' && block.data && Array.isArray(block.data.items)) {
174
+ return {
175
+ ...block,
176
+ data: {
177
+ ...block.data,
178
+ items: block.data.items.map(item => {
179
+ // Handle both string items and object items
180
+ if (typeof item === 'string') {
181
+ return {
182
+ content: item,
183
+ items: []
184
+ }
185
+ } else if (item.content) {
186
+ return {
187
+ content: item.content,
188
+ items: Array.isArray(item.items) ? item.items : []
189
+ }
190
+ } else {
191
+ return {
192
+ content: String(item),
193
+ items: []
194
+ }
195
+ }
196
+ })
197
+ }
198
+ }
155
199
  }
200
+ return block
201
+ })
202
+ }
203
+ }
156
204
 
157
- // Call the ready hook if it exists
158
- if (typeof window.onEditorJSReady === 'function') {
159
- window.onEditorJSReady(editor)
205
+ console.debug('[Panda CMS] Processed initial data:', processedData)
206
+
207
+ // Create editor configuration
208
+ const config = {
209
+ holder: holder,
210
+ data: processedData,
211
+ placeholder: 'Click to start writing...',
212
+ tools: {
213
+ paragraph: {
214
+ class: win.Paragraph,
215
+ inlineToolbar: true,
216
+ config: {
217
+ preserveBlank: true,
218
+ placeholder: 'Click to start writing...'
160
219
  }
161
- } catch (error) {
162
- console.error('Error during editor redraw:', error)
163
- }
164
- }, 100)
165
- },
166
- onChange: async (api, event) => {
167
- try {
168
- // Save the current editor data
169
- const outputData = await api.saver.save()
170
- outputData.source = "editorJS"
171
- const contentJson = JSON.stringify(outputData)
172
-
173
- if (!this.withinIFrame) {
174
- // For form-based editors, update the hidden input
175
- const form = element.closest('[data-controller="editor-form"]')
176
- if (form) {
177
- const hiddenInput = form.querySelector('[data-editor-form-target="hiddenField"]')
178
- if (hiddenInput) {
179
- hiddenInput.value = contentJson
180
- hiddenInput.dataset.initialContent = contentJson
181
- hiddenInput.dispatchEvent(new Event('change', { bubbles: true }))
182
- }
220
+ },
221
+ header: {
222
+ class: win.Header,
223
+ inlineToolbar: true,
224
+ config: {
225
+ placeholder: 'Enter a header',
226
+ levels: [1, 2, 3, 4, 5, 6],
227
+ defaultLevel: 2
183
228
  }
184
- } else {
185
- // For iframe-based editors, update the element's data attribute
186
- element.setAttribute('data-content', contentJson)
187
- element.dispatchEvent(new Event('change', { bubbles: true }))
188
-
189
- // Get the save button from parent window
190
- const saveButton = parent.document.getElementById('saveEditableButton')
191
- if (saveButton) {
192
- // Store the current content on the save button for later use
193
- saveButton.dataset.pendingContent = contentJson
194
-
195
- // Add click handler if not already added
196
- if (!saveButton.hasAttribute('data-handler-attached')) {
197
- saveButton.setAttribute('data-handler-attached', 'true')
198
- saveButton.addEventListener('click', async () => {
199
- try {
200
- const pageId = element.getAttribute("data-editable-page-id")
201
- const blockContentId = element.getAttribute("data-editable-block-content-id")
202
- const pendingContent = JSON.parse(saveButton.dataset.pendingContent || '{}')
203
-
204
- const response = await fetch(`${this.adminPathValue}/pages/${pageId}/block_contents/${blockContentId}`, {
205
- method: "PATCH",
206
- headers: {
207
- "Content-Type": "application/json",
208
- "X-CSRF-Token": this.csrfToken
209
- },
210
- body: JSON.stringify({ content: pendingContent })
211
- })
212
-
213
- if (!response.ok) {
214
- throw new Error('Save failed')
215
- }
216
-
217
- // Clear pending content after successful save
218
- delete saveButton.dataset.pendingContent
219
- } catch (error) {
220
- console.error('Error saving content:', error)
221
- }
222
- })
223
- }
229
+ },
230
+ 'list': { // Register as list instead of nested-list
231
+ class: win.NestedList,
232
+ inlineToolbar: true,
233
+ config: {
234
+ defaultStyle: 'unordered',
235
+ enableLineBreaks: true
236
+ }
237
+ },
238
+ quote: {
239
+ class: win.Quote,
240
+ inlineToolbar: true,
241
+ config: {
242
+ quotePlaceholder: 'Enter a quote',
243
+ captionPlaceholder: 'Quote\'s author'
224
244
  }
225
245
  }
226
- } catch (error) {
227
- console.error('Error in onChange handler:', error)
246
+ },
247
+ onChange: (api, event) => {
248
+ console.debug('[Panda CMS] Editor content changed:', { api, event })
249
+ // Save content to data attributes
250
+ api.saver.save().then((outputData) => {
251
+ const jsonString = JSON.stringify(outputData)
252
+ element.dataset.editablePreviousData = btoa(jsonString)
253
+ element.dataset.editableContent = jsonString
254
+ element.dataset.editableInitialized = 'true'
255
+ })
256
+ },
257
+ onReady: () => {
258
+ console.debug('[Panda CMS] Editor ready with data:', processedData)
259
+ element.dataset.editableInitialized = 'true'
260
+ holder.editorInstance = editor
261
+ },
262
+ onError: (error) => {
263
+ console.error('[Panda CMS] Editor error:', error)
264
+ element.dataset.editableInitialized = 'false'
265
+ throw error
228
266
  }
229
267
  }
230
- })
231
-
232
- // Store editor instance on the holder element to maintain reference
233
- holderElement.editorInstance = editor
234
-
235
- if (!this.withinIFrame) {
236
- // Store the editor instance on the controller element for potential future reference
237
- const form = element.closest('[data-controller="editor-form"]')
238
- if (form) {
239
- form.editorInstance = editor
240
- }
241
- } else {
242
- // For iframe editors, store the instance on the element itself
243
- element.editorInstance = editor
244
- }
245
-
246
- // Return a promise that resolves when the editor is ready
247
- return new Promise((resolve, reject) => {
248
- const timeout = setTimeout(() => {
249
- reject(new Error('Editor initialization timed out'))
250
- }, 30000)
251
268
 
252
- const checkReady = () => {
253
- if (editor.isReady) {
254
- clearTimeout(timeout)
255
- resolve(editor)
256
- } else {
257
- setTimeout(checkReady, 100)
258
- }
259
- }
260
- checkReady()
261
- })
262
- }
269
+ // Remove any undefined tools from the config
270
+ config.tools = Object.fromEntries(
271
+ Object.entries(config.tools)
272
+ .filter(([_, value]) => value?.class !== undefined)
273
+ )
263
274
 
264
- /**
265
- * Wait for EditorJS core to be available in window
266
- */
267
- async waitForEditorJS() {
268
- let attempts = 0
269
- const maxAttempts = 30 // 3 seconds with 100ms intervals
275
+ console.debug('[Panda CMS] Creating editor with config:', config)
270
276
 
271
- await new Promise((resolve, reject) => {
272
- const check = () => {
273
- attempts++
274
- if (window.EditorJS) {
275
- resolve()
276
- } else if (attempts >= maxAttempts) {
277
- reject(new Error('EditorJS core failed to load'))
278
- } else {
279
- setTimeout(check, 100)
277
+ // Create editor instance with extended timeout
278
+ return new Promise((resolve, reject) => {
279
+ try {
280
+ // Add timeout for initialization
281
+ const timeoutId = setTimeout(() => {
282
+ reject(new Error('Editor initialization timeout'))
283
+ }, 15000) // Increased to 15 seconds
284
+
285
+ // Create editor instance with onReady callback
286
+ const editor = new win.EditorJS({
287
+ ...config,
288
+ onReady: () => {
289
+ console.debug('[Panda CMS] Editor ready with data:', processedData)
290
+ clearTimeout(timeoutId)
291
+ holder.editorInstance = editor
292
+ element.dataset.editableInitialized = 'true'
293
+ resolve(editor)
294
+ },
295
+ onChange: (api, event) => {
296
+ console.debug('[Panda CMS] Editor content changed:', { api, event })
297
+ // Save content to data attributes
298
+ api.saver.save().then((outputData) => {
299
+ const jsonString = JSON.stringify(outputData)
300
+ element.dataset.editablePreviousData = btoa(jsonString)
301
+ element.dataset.editableContent = jsonString
302
+ })
303
+ },
304
+ onError: (error) => {
305
+ console.error('[Panda CMS] Editor error:', error)
306
+ element.dataset.editableInitialized = 'false'
307
+ clearTimeout(timeoutId)
308
+ reject(error)
309
+ }
310
+ })
311
+
312
+ // Add error handler
313
+ editor.isReady
314
+ .then(() => {
315
+ console.debug('[Panda CMS] Editor is ready')
316
+ element.dataset.editableInitialized = 'true'
317
+ })
318
+ .catch((error) => {
319
+ console.error('[Panda CMS] Editor failed to initialize:', error)
320
+ element.dataset.editableInitialized = 'false'
321
+ clearTimeout(timeoutId)
322
+ reject(error)
323
+ })
324
+ } catch (error) {
325
+ element.dataset.editableInitialized = 'false'
326
+ reject(error)
280
327
  }
281
- }
282
- check()
283
- })
328
+ })
329
+ } catch (error) {
330
+ console.error('[Panda CMS] Error initializing editor:', error)
331
+ throw error
332
+ }
284
333
  }
285
334
  }
@@ -105,6 +105,6 @@ export class PlainTextEditor {
105
105
  this.element.style.backgroundColor = "inherit"
106
106
  }, 1000)
107
107
  console.log(error)
108
- alert("Error:", error)
108
+ alert(`Error: ${error}`)
109
109
  }
110
110
  }
@@ -112,4 +112,93 @@ export class ResourceLoader {
112
112
  }
113
113
  return hash.toString(36)
114
114
  }
115
+
116
+ /**
117
+ * Load a script into the document
118
+ * @param {Document} doc - The document to load the script into
119
+ * @param {HTMLElement} target - The element to append the script to
120
+ * @param {string} url - The URL of the script to load
121
+ * @returns {Promise<void>}
122
+ */
123
+ static async loadScript(doc, target, url) {
124
+ return new ResourceLoader().loadScript(doc, target, url)
125
+ }
126
+
127
+ /**
128
+ * Embed CSS into the document
129
+ * @param {Document} doc - The document to embed the CSS into
130
+ * @param {HTMLElement} target - The element to append the style to
131
+ * @param {string} css - The CSS to embed
132
+ * @returns {Promise<void>}
133
+ */
134
+ static async embedCSS(doc, target, css) {
135
+ return new ResourceLoader().embedCSS(doc, target, css)
136
+ }
137
+
138
+ /**
139
+ * Instance method to load a script
140
+ */
141
+ async loadScript(doc, target, url) {
142
+ try {
143
+ // Check if script is already loaded
144
+ const existingScript = doc.querySelector(`script[src="${url}"]`)
145
+ if (existingScript) {
146
+ console.debug(`[Panda CMS] Script already loaded: ${url}, skipping`)
147
+ return
148
+ }
149
+
150
+ // Create and configure script element
151
+ const script = doc.createElement("script")
152
+ script.type = "text/javascript"
153
+ script.src = url
154
+ script.async = true
155
+
156
+ // Create a promise to track loading
157
+ const loadPromise = new Promise((resolve, reject) => {
158
+ script.onload = () => {
159
+ console.debug(`[Panda CMS] Script loaded: ${url}`)
160
+ resolve()
161
+ }
162
+ script.onerror = (error) => {
163
+ console.error(`[Panda CMS] Script failed to load: ${url}`, error)
164
+ reject(error)
165
+ }
166
+ })
167
+
168
+ // Add script to document
169
+ target.appendChild(script)
170
+
171
+ // Wait for script to load
172
+ await loadPromise
173
+ } catch (error) {
174
+ console.error(`[Panda CMS] Error loading script ${url}:`, error)
175
+ throw error
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Instance method to embed CSS
181
+ */
182
+ async embedCSS(doc, target, css) {
183
+ try {
184
+ // Check if styles are already embedded
185
+ const existingStyle = doc.querySelector('style[data-panda-cms-styles]')
186
+ if (existingStyle) {
187
+ console.debug(`[Panda CMS] CSS already embedded, skipping`)
188
+ return
189
+ }
190
+
191
+ // Create and configure style element
192
+ const style = doc.createElement('style')
193
+ style.setAttribute('data-panda-cms-styles', 'true')
194
+ style.textContent = css
195
+
196
+ // Add style to document
197
+ target.appendChild(style)
198
+ console.debug(`[Panda CMS] Embedded CSS styles`)
199
+ } catch (error) {
200
+ console.error('[Panda CMS] Error embedding CSS:', error)
201
+ throw error
202
+ }
203
+ }
115
204
  }