panda-editor 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,162 @@
1
+ import EditorJS from "@editorjs/editorjs"
2
+ import Paragraph from "@editorjs/paragraph"
3
+ import Header from "@editorjs/header"
4
+ import List from "@editorjs/list"
5
+ import Quote from "@editorjs/quote"
6
+ import Table from "@editorjs/table"
7
+ import NestedList from "@editorjs/nested-list"
8
+
9
+ export default class RichTextEditor {
10
+ constructor(element, iframe) {
11
+ this.element = element
12
+ this.iframe = iframe
13
+ this.editor = null
14
+ this.initialized = false
15
+ this.initialize()
16
+ }
17
+
18
+ async initialize() {
19
+ if (this.initialized) return
20
+ console.debug("[Panda CMS] Initializing EditorJS")
21
+
22
+ try {
23
+ let content = this.element.dataset.editableContent || ""
24
+ let previousData = this.element.dataset.editablePreviousData || ""
25
+ console.debug("[Panda CMS] Initial content:", content)
26
+ console.debug("[Panda CMS] Previous data:", previousData)
27
+
28
+ let parsedContent
29
+ if (previousData) {
30
+ try {
31
+ // Try to decode base64 first
32
+ const decodedData = atob(previousData)
33
+ console.debug("[Panda CMS] Decoded base64 data:", decodedData)
34
+ parsedContent = JSON.parse(decodedData)
35
+ console.debug("[Panda CMS] Successfully parsed base64 data:", parsedContent)
36
+ } catch (e) {
37
+ console.debug("[Panda CMS] Not base64 encoded or invalid, trying direct JSON parse:", e)
38
+ try {
39
+ parsedContent = JSON.parse(previousData)
40
+ console.debug("[Panda CMS] Successfully parsed JSON data:", parsedContent)
41
+ } catch (e2) {
42
+ console.error("[Panda CMS] Failed to parse previous data:", e2)
43
+ parsedContent = this.getDefaultContent()
44
+ }
45
+ }
46
+ } else if (content) {
47
+ try {
48
+ parsedContent = JSON.parse(content)
49
+ console.debug("[Panda CMS] Successfully parsed content:", parsedContent)
50
+ } catch (e) {
51
+ console.error("[Panda CMS] Failed to parse content:", e)
52
+ parsedContent = this.getDefaultContent()
53
+ }
54
+ } else {
55
+ parsedContent = this.getDefaultContent()
56
+ }
57
+
58
+ // Create holder element before initialization
59
+ const holderId = `editor-${Math.random().toString(36).substr(2, 9)}`
60
+ const holderElement = document.createElement("div")
61
+ holderElement.id = holderId
62
+ holderElement.className = "editor-js-holder codex-editor"
63
+
64
+ // Clear any existing content and append holder
65
+ this.element.textContent = ""
66
+ this.element.appendChild(holderElement)
67
+
68
+ // Initialize EditorJS
69
+ this.editor = new EditorJS({
70
+ holder: holderId,
71
+ data: parsedContent,
72
+ placeholder: "Click to start writing...",
73
+ tools: {
74
+ paragraph: {
75
+ class: Paragraph,
76
+ inlineToolbar: true
77
+ },
78
+ header: {
79
+ class: Header,
80
+ inlineToolbar: true
81
+ },
82
+ list: {
83
+ class: NestedList,
84
+ inlineToolbar: true,
85
+ config: {
86
+ defaultStyle: 'unordered',
87
+ enableLineBreaks: true
88
+ }
89
+ },
90
+ quote: {
91
+ class: Quote,
92
+ inlineToolbar: true
93
+ },
94
+ table: {
95
+ class: Table,
96
+ inlineToolbar: true
97
+ }
98
+ },
99
+ onChange: () => {
100
+ this.save()
101
+ }
102
+ })
103
+
104
+ await this.editor.isReady
105
+ this.initialized = true
106
+ console.debug("[Panda CMS] EditorJS initialized successfully")
107
+ } catch (error) {
108
+ console.error("[Panda CMS] Error initializing EditorJS:", error)
109
+ }
110
+ }
111
+
112
+ getDefaultContent() {
113
+ return {
114
+ time: Date.now(),
115
+ blocks: [
116
+ {
117
+ type: "paragraph",
118
+ data: {
119
+ text: ""
120
+ }
121
+ }
122
+ ],
123
+ version: "2.28.2"
124
+ }
125
+ }
126
+
127
+ async save() {
128
+ if (!this.editor) return null
129
+
130
+ try {
131
+ const savedData = await this.editor.save()
132
+ const jsonString = JSON.stringify(savedData)
133
+ // Store both base64 and regular JSON
134
+ this.element.dataset.editablePreviousData = btoa(jsonString)
135
+ this.element.dataset.editableContent = jsonString
136
+ return jsonString
137
+ } catch (error) {
138
+ console.error("[Panda CMS] Error saving EditorJS content:", error)
139
+ return null
140
+ }
141
+ }
142
+
143
+ async clear() {
144
+ if (!this.editor) return
145
+
146
+ try {
147
+ await this.editor.clear()
148
+ this.element.dataset.editablePreviousData = ""
149
+ this.element.dataset.editableContent = ""
150
+ } catch (error) {
151
+ console.error("[Panda CMS] Error clearing EditorJS content:", error)
152
+ }
153
+ }
154
+
155
+ destroy() {
156
+ if (this.editor) {
157
+ this.editor.destroy()
158
+ this.editor = null
159
+ this.initialized = false
160
+ }
161
+ }
162
+ }
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Editor
5
+ class HtmlToEditorJsConverter
6
+ class ConversionError < StandardError; end
7
+
8
+ def self.convert(html)
9
+ return {} if html.blank?
10
+
11
+ # If it's already in EditorJS format, return as is
12
+ return html if html.is_a?(Hash) && (html["blocks"].present? || html[:blocks].present?)
13
+
14
+ begin
15
+ # Parse the HTML content
16
+ doc = Nokogiri::HTML.fragment(html.to_s)
17
+ raise ConversionError, "Failed to parse HTML content" unless doc
18
+
19
+ blocks = []
20
+ current_text = ""
21
+
22
+ doc.children.each do |node|
23
+ case node.name
24
+ when "h1", "h2", "h3", "h4", "h5", "h6"
25
+ # Add any accumulated text as a paragraph before the header
26
+ if current_text.present?
27
+ blocks << create_paragraph_block(current_text)
28
+ current_text = ""
29
+ end
30
+
31
+ blocks << {
32
+ "type" => "header",
33
+ "data" => {
34
+ "text" => node.text.strip,
35
+ "level" => node.name[1].to_i
36
+ }
37
+ }
38
+ when "p", "div"
39
+ # Add any accumulated text first
40
+ if current_text.present?
41
+ blocks << create_paragraph_block(current_text)
42
+ current_text = ""
43
+ end
44
+
45
+ if node.name == "div"
46
+ # Process div children separately
47
+ node.children.each do |child|
48
+ case child.name
49
+ when "h1", "h2", "h3", "h4", "h5", "h6"
50
+ blocks << {
51
+ "type" => "header",
52
+ "data" => {
53
+ "text" => child.text.strip,
54
+ "level" => child.name[1].to_i
55
+ }
56
+ }
57
+ when "p"
58
+ text = process_inline_elements(child)
59
+ paragraphs = text.split(%r{<br\s*/?>\s*<br\s*/?>}).map(&:strip)
60
+ paragraphs.each do |paragraph|
61
+ blocks << create_paragraph_block(paragraph) if paragraph.present?
62
+ end
63
+ when "ul", "ol"
64
+ items = child.css("li").map { |li| process_inline_elements(li) }
65
+ next if items.empty?
66
+
67
+ blocks << {
68
+ "type" => "list",
69
+ "data" => {
70
+ "style" => (child.name == "ul") ? "unordered" : "ordered",
71
+ "items" => items
72
+ }
73
+ }
74
+ when "blockquote"
75
+ blocks << {
76
+ "type" => "quote",
77
+ "data" => {
78
+ "text" => process_inline_elements(child),
79
+ "caption" => "",
80
+ "alignment" => "left"
81
+ }
82
+ }
83
+ when "text"
84
+ text = child.text.strip
85
+ current_text += text if text.present?
86
+ end
87
+ end
88
+ else
89
+ # Handle p with nested content
90
+ text = process_inline_elements(node)
91
+ paragraphs = text.split(%r{<br\s*/?>\s*<br\s*/?>}).map(&:strip)
92
+ paragraphs.each do |paragraph|
93
+ blocks << create_paragraph_block(paragraph) if paragraph.present?
94
+ end
95
+ end
96
+ when "br"
97
+ current_text += "\n\n"
98
+ when "text"
99
+ text = node.text.strip
100
+ current_text += text if text.present?
101
+ when "ul", "ol"
102
+ # Add any accumulated text first
103
+ if current_text.present?
104
+ blocks << create_paragraph_block(current_text)
105
+ current_text = ""
106
+ end
107
+
108
+ items = node.css("li").map { |li| process_inline_elements(li) }
109
+ next if items.empty?
110
+
111
+ blocks << {
112
+ "type" => "list",
113
+ "data" => {
114
+ "style" => (node.name == "ul") ? "unordered" : "ordered",
115
+ "items" => items
116
+ }
117
+ }
118
+ when "blockquote"
119
+ # Add any accumulated text first
120
+ if current_text.present?
121
+ blocks << create_paragraph_block(current_text)
122
+ current_text = ""
123
+ end
124
+
125
+ blocks << {
126
+ "type" => "quote",
127
+ "data" => {
128
+ "text" => process_inline_elements(node),
129
+ "caption" => "",
130
+ "alignment" => "left"
131
+ }
132
+ }
133
+ end
134
+ end
135
+
136
+ # Add any remaining text
137
+ blocks << create_paragraph_block(current_text) if current_text.present?
138
+
139
+ # Return the complete EditorJS structure
140
+ {
141
+ "time" => Time.current.to_i * 1000,
142
+ "blocks" => blocks,
143
+ "version" => "2.28.2"
144
+ }
145
+ rescue => e
146
+ Rails.logger.error "HTML to EditorJS conversion failed: #{e.message}"
147
+ Rails.logger.error e.backtrace.join("\n")
148
+ raise ConversionError, "Failed to convert HTML to EditorJS format: #{e.message}"
149
+ end
150
+ end
151
+
152
+ def self.create_paragraph_block(text)
153
+ {
154
+ "type" => "paragraph",
155
+ "data" => {
156
+ "text" => text.strip
157
+ }
158
+ }
159
+ end
160
+
161
+ def self.process_inline_elements(node)
162
+ result = ""
163
+ node.children.each do |child|
164
+ case child.name
165
+ when "br"
166
+ result += "<br>"
167
+ when "text"
168
+ result += child.text
169
+ when "strong", "b"
170
+ result += "<b>#{child.text}</b>"
171
+ when "em", "i"
172
+ result += "<i>#{child.text}</i>"
173
+ when "a"
174
+ href = child["href"]
175
+ text = child.text.strip
176
+ # Handle email links specially
177
+ if href&.start_with?("mailto:")
178
+ email = href.sub("mailto:", "")
179
+ result += "<a href=\"mailto:#{email}\">#{text}</a>"
180
+ else
181
+ result += "<a href=\"#{href}\">#{text}</a>"
182
+ end
183
+ else
184
+ result += if child.text?
185
+ child.text
186
+ else
187
+ child.to_html
188
+ end
189
+ end
190
+ end
191
+ result.strip
192
+ end
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Pin npm packages by running ./bin/importmap
4
+
5
+ pin_all_from "app/javascript/panda/editor", under: "panda/editor"
6
+
7
+ # EditorJS Core and plugins (from CDN)
8
+ pin "@editorjs/editorjs", to: "https://cdn.jsdelivr.net/npm/@editorjs/editorjs@2.28.2/+esm"
9
+ pin "@editorjs/paragraph", to: "https://cdn.jsdelivr.net/npm/@editorjs/paragraph@2.11.3/+esm"
10
+ pin "@editorjs/header", to: "https://cdn.jsdelivr.net/npm/@editorjs/header@2.8.1/+esm"
11
+ pin "@editorjs/nested-list", to: "https://cdn.jsdelivr.net/npm/@editorjs/nested-list@1.4.2/+esm"
12
+ pin "@editorjs/quote", to: "https://cdn.jsdelivr.net/npm/@editorjs/quote@2.6.0/+esm"
13
+ pin "@editorjs/simple-image", to: "https://cdn.jsdelivr.net/npm/@editorjs/simple-image@1.6.0/+esm"
14
+ pin "@editorjs/table", to: "https://cdn.jsdelivr.net/npm/@editorjs/table@2.3.0/+esm"
15
+ pin "@editorjs/embed", to: "https://cdn.jsdelivr.net/npm/@editorjs/embed@2.7.0/+esm"
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+
6
+ module Panda
7
+ module Editor
8
+ class AssetLoader
9
+ GITHUB_RELEASES_URL = "https://api.github.com/repos/tastybamboo/panda-editor/releases/latest"
10
+ ASSET_CACHE_DIR = Rails.root.join("tmp", "panda_editor_assets")
11
+
12
+ class << self
13
+ def load_assets
14
+ if use_compiled_assets?
15
+ load_compiled_assets
16
+ else
17
+ load_development_assets
18
+ end
19
+ end
20
+
21
+ def javascript_url
22
+ if use_compiled_assets?
23
+ compiled_javascript_url
24
+ else
25
+ "/assets/panda/editor/application.js"
26
+ end
27
+ end
28
+
29
+ def stylesheet_url
30
+ if use_compiled_assets?
31
+ compiled_stylesheet_url
32
+ else
33
+ "/assets/panda/editor/application.css"
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def use_compiled_assets?
40
+ Rails.env.production? ||
41
+ Rails.env.test? ||
42
+ ENV["PANDA_EDITOR_USE_COMPILED_ASSETS"] == "true"
43
+ end
44
+
45
+ def load_compiled_assets
46
+ ensure_assets_downloaded
47
+ {
48
+ javascript: compiled_javascript_url,
49
+ stylesheet: compiled_stylesheet_url
50
+ }
51
+ end
52
+
53
+ def load_development_assets
54
+ {
55
+ javascript: "/assets/panda/editor/application.js",
56
+ stylesheet: "/assets/panda/editor/application.css"
57
+ }
58
+ end
59
+
60
+ def compiled_javascript_url
61
+ asset_path = find_latest_asset("js")
62
+ asset_path ? "/panda-editor-assets/#{File.basename(asset_path)}" : nil
63
+ end
64
+
65
+ def compiled_stylesheet_url
66
+ asset_path = find_latest_asset("css")
67
+ asset_path ? "/panda-editor-assets/#{File.basename(asset_path)}" : nil
68
+ end
69
+
70
+ def find_latest_asset(extension)
71
+ pattern = Rails.root.join("public", "panda-editor-assets", "panda-editor-*.#{extension}")
72
+ Dir.glob(pattern).max_by { |f| File.mtime(f) }
73
+ end
74
+
75
+ def ensure_assets_downloaded
76
+ return if assets_exist?
77
+
78
+ download_assets_from_github
79
+ end
80
+
81
+ def assets_exist?
82
+ js_exists = Dir.glob(Rails.root.join("public", "panda-editor-assets", "panda-editor-*.js")).any?
83
+ css_exists = Dir.glob(Rails.root.join("public", "panda-editor-assets", "panda-editor-*.css")).any?
84
+ js_exists && css_exists
85
+ end
86
+
87
+ def download_assets_from_github
88
+ Rails.logger.info "[Panda Editor] Downloading assets from GitHub releases..."
89
+
90
+ begin
91
+ release_data = fetch_latest_release
92
+ download_release_assets(release_data["assets"])
93
+ rescue => e
94
+ Rails.logger.error "[Panda Editor] Failed to download assets: #{e.message}"
95
+ use_fallback_assets
96
+ end
97
+ end
98
+
99
+ def fetch_latest_release
100
+ uri = URI(GITHUB_RELEASES_URL)
101
+ response = Net::HTTP.get_response(uri)
102
+
103
+ if response.code == "200"
104
+ JSON.parse(response.body)
105
+ else
106
+ raise "GitHub API returned #{response.code}"
107
+ end
108
+ end
109
+
110
+ def download_release_assets(assets)
111
+ assets.each do |asset|
112
+ next unless asset["name"].match?(/panda-editor.*\.(js|css)$/)
113
+
114
+ download_asset(asset)
115
+ end
116
+ end
117
+
118
+ def download_asset(asset)
119
+ uri = URI(asset["browser_download_url"])
120
+ response = Net::HTTP.get_response(uri)
121
+
122
+ if response.code == "200"
123
+ save_asset(asset["name"], response.body)
124
+ end
125
+ end
126
+
127
+ def save_asset(filename, content)
128
+ dir = Rails.root.join("public", "panda-editor-assets")
129
+ FileUtils.mkdir_p(dir)
130
+
131
+ File.write(dir.join(filename), content)
132
+ Rails.logger.info "[Panda Editor] Downloaded #{filename}"
133
+ end
134
+
135
+ def use_fallback_assets
136
+ Rails.logger.warn "[Panda Editor] Using fallback embedded assets"
137
+ # Copy embedded assets from gem to public directory
138
+ copy_embedded_assets
139
+ end
140
+
141
+ def copy_embedded_assets
142
+ source_dir = Panda::Editor::Engine.root.join("public", "panda-editor-assets")
143
+ dest_dir = Rails.root.join("public", "panda-editor-assets")
144
+
145
+ FileUtils.mkdir_p(dest_dir)
146
+ FileUtils.cp_r(Dir.glob(source_dir.join("*")), dest_dir)
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Editor
5
+ module Blocks
6
+ class Alert < Base
7
+ def render
8
+ message = sanitize(data["message"])
9
+ type = data["type"] || "primary"
10
+
11
+ html_safe(
12
+ "<div class=\"#{alert_classes(type)} p-4 mb-4 rounded-lg\">" \
13
+ "#{message}" \
14
+ "</div>"
15
+ )
16
+ end
17
+
18
+ private
19
+
20
+ def alert_classes(type)
21
+ case type
22
+ when "primary" then "bg-blue-100 text-blue-800"
23
+ when "secondary" then "bg-gray-100 text-gray-800"
24
+ when "success" then "bg-green-100 text-green-800"
25
+ when "danger" then "bg-red-100 text-red-800"
26
+ when "warning" then "bg-yellow-100 text-yellow-800"
27
+ when "info" then "bg-indigo-100 text-indigo-800"
28
+ else "bg-blue-100 text-blue-800"
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Editor
5
+ module Blocks
6
+ class Base
7
+ include ActionView::Helpers::SanitizeHelper
8
+ include ActionView::Helpers::TagHelper
9
+
10
+ attr_reader :data, :options
11
+
12
+ def initialize(data, options = {})
13
+ @data = data
14
+ @options = options
15
+ end
16
+
17
+ def render
18
+ ""
19
+ end
20
+
21
+ protected
22
+
23
+ def html_safe(content)
24
+ content.html_safe
25
+ end
26
+
27
+ def sanitize(text)
28
+ Rails::Html::SafeListSanitizer.new.sanitize(text, tags: %w[b i u a code])
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Editor
5
+ module Blocks
6
+ class Header < Base
7
+ def render
8
+ content = sanitize(data["text"])
9
+ level = data["level"] || 2
10
+ html_safe("<h#{level}>#{content}</h#{level}>")
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Editor
5
+ module Blocks
6
+ class Image < Base
7
+ def render
8
+ url = data["url"]
9
+ caption = sanitize(data["caption"])
10
+ with_border = data["withBorder"]
11
+ with_background = data["withBackground"]
12
+ stretched = data["stretched"]
13
+
14
+ css_classes = ["prose"]
15
+ css_classes << "border" if with_border
16
+ css_classes << "bg-gray-100" if with_background
17
+ css_classes << "w-full" if stretched
18
+
19
+ html_safe(<<~HTML)
20
+ <figure class="#{css_classes.join(" ")}">
21
+ <img src="#{url}" alt="#{caption}" />
22
+ #{caption_element(caption)}
23
+ </figure>
24
+ HTML
25
+ end
26
+
27
+ private
28
+
29
+ def caption_element(caption)
30
+ return "" if caption.blank?
31
+
32
+ "<figcaption>#{caption}</figcaption>"
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Editor
5
+ module Blocks
6
+ class List < Base
7
+ def render
8
+ list_type = (data["style"] == "ordered") ? "ol" : "ul"
9
+ html_safe(
10
+ "<#{list_type}>" \
11
+ "#{render_items(data["items"])}" \
12
+ "</#{list_type}>"
13
+ )
14
+ end
15
+
16
+ private
17
+
18
+ def render_items(items)
19
+ items.map do |item|
20
+ content = item.is_a?(Hash) ? item["content"] : item
21
+ nested = (item.is_a?(Hash) && item["items"].present?) ? render_nested(item["items"]) : ""
22
+ "<li>#{sanitize(content)}#{nested}</li>"
23
+ end.join
24
+ end
25
+
26
+ def render_nested(items)
27
+ self.class.new({"items" => items, "style" => data["style"]}).render
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Editor
5
+ module Blocks
6
+ class Paragraph < Base
7
+ def render
8
+ content = sanitize(data["text"])
9
+ return "" if content.blank?
10
+
11
+ html_safe("<p>#{content}</p>")
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end