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.
- checksums.yaml +7 -0
- data/LICENSE +28 -0
- data/README.md +146 -0
- data/app/javascript/panda/editor/application.js +25 -0
- data/app/javascript/panda/editor/css_extractor.js +80 -0
- data/app/javascript/panda/editor/editor_js_config.js +306 -0
- data/app/javascript/panda/editor/editor_js_initializer.js +334 -0
- data/app/javascript/panda/editor/plain_text_editor.js +110 -0
- data/app/javascript/panda/editor/resource_loader.js +204 -0
- data/app/javascript/panda/editor/rich_text_editor.js +162 -0
- data/app/services/panda/editor/html_to_editor_js_converter.rb +195 -0
- data/config/importmap.rb +15 -0
- data/lib/panda/editor/asset_loader.rb +151 -0
- data/lib/panda/editor/blocks/alert.rb +34 -0
- data/lib/panda/editor/blocks/base.rb +33 -0
- data/lib/panda/editor/blocks/header.rb +15 -0
- data/lib/panda/editor/blocks/image.rb +37 -0
- data/lib/panda/editor/blocks/list.rb +32 -0
- data/lib/panda/editor/blocks/paragraph.rb +16 -0
- data/lib/panda/editor/blocks/quote.rb +42 -0
- data/lib/panda/editor/blocks/table.rb +50 -0
- data/lib/panda/editor/content.rb +61 -0
- data/lib/panda/editor/engine.rb +34 -0
- data/lib/panda/editor/renderer.rb +125 -0
- data/lib/panda/editor/version.rb +7 -0
- data/lib/panda/editor.rb +25 -0
- data/lib/tasks/assets.rake +110 -0
- data/panda-editor.gemspec +40 -0
- metadata +142 -0
@@ -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
|
data/config/importmap.rb
ADDED
@@ -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
|