panda-editor 0.5.0 → 0.8.2

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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -0
  3. data/CHANGELOG.md +19 -0
  4. data/README.md +68 -0
  5. data/app/javascript/panda/editor/application.js +8 -6
  6. data/app/javascript/panda/editor/controllers/index.js +5 -0
  7. data/app/javascript/panda/editor/editor_js_config.js +7 -5
  8. data/app/javascript/panda/editor/editor_js_initializer.js +10 -3
  9. data/app/javascript/panda/editor/plugins/embed.min.js +2 -0
  10. data/app/javascript/panda/editor/plugins/header.min.js +9 -0
  11. data/app/javascript/panda/editor/plugins/nested-list.min.js +2 -0
  12. data/app/javascript/panda/editor/plugins/paragraph.min.js +9 -0
  13. data/app/javascript/panda/editor/plugins/quote.min.js +2 -0
  14. data/app/javascript/panda/editor/plugins/simple-image.min.js +2 -0
  15. data/app/javascript/panda/editor/plugins/table.min.js +2 -0
  16. data/app/javascript/panda/editor/rich_text_editor.js +2 -3
  17. data/app/services/panda/editor/html_to_editor_js_converter.rb +68 -68
  18. data/app/stylesheets/editor.css +120 -0
  19. data/config/importmap.rb +22 -11
  20. data/docs/FOOTNOTES.md +96 -3
  21. data/lefthook.yml +16 -0
  22. data/lib/panda/editor/asset_loader.rb +27 -27
  23. data/lib/panda/editor/blocks/alert.rb +10 -10
  24. data/lib/panda/editor/blocks/base.rb +1 -1
  25. data/lib/panda/editor/blocks/header.rb +2 -2
  26. data/lib/panda/editor/blocks/image.rb +11 -11
  27. data/lib/panda/editor/blocks/list.rb +25 -6
  28. data/lib/panda/editor/blocks/paragraph.rb +41 -10
  29. data/lib/panda/editor/blocks/quote.rb +6 -6
  30. data/lib/panda/editor/blocks/table.rb +6 -6
  31. data/lib/panda/editor/content.rb +11 -8
  32. data/lib/panda/editor/engine.rb +33 -7
  33. data/lib/panda/editor/footnote_registry.rb +10 -5
  34. data/lib/panda/editor/html_to_editor_js_converter.rb +159 -0
  35. data/lib/panda/editor/markdown_to_editor_js_converter.rb +50 -0
  36. data/lib/panda/editor/renderer.rb +31 -31
  37. data/lib/panda/editor/version.rb +1 -1
  38. data/lib/panda/editor.rb +19 -15
  39. data/lib/panda-editor.rb +5 -0
  40. data/lib/tasks/assets.rake +27 -27
  41. data/mise.toml +2 -0
  42. data/panda-editor.gemspec +25 -23
  43. metadata +49 -3
@@ -9,78 +9,78 @@ module Panda
9
9
  return {} if html.blank?
10
10
 
11
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?)
12
+ return html if html.is_a?(Hash) && (html["blocks"].present? || html[:blocks].present?)
13
13
 
14
14
  begin
15
15
  # Parse the HTML content
16
16
  doc = Nokogiri::HTML.fragment(html.to_s)
17
- raise ConversionError, 'Failed to parse HTML content' unless doc
17
+ raise ConversionError, "Failed to parse HTML content" unless doc
18
18
 
19
19
  blocks = []
20
- current_text = ''
20
+ current_text = ""
21
21
 
22
22
  doc.children.each do |node|
23
23
  case node.name
24
- when 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'
24
+ when "h1", "h2", "h3", "h4", "h5", "h6"
25
25
  # Add any accumulated text as a paragraph before the header
26
26
  if current_text.present?
27
27
  blocks << create_paragraph_block(current_text)
28
- current_text = ''
28
+ current_text = ""
29
29
  end
30
30
 
31
31
  blocks << {
32
- 'type' => 'header',
33
- 'data' => {
34
- 'text' => node.text.strip,
35
- 'level' => node.name[1].to_i
32
+ "type" => "header",
33
+ "data" => {
34
+ "text" => node.text.strip,
35
+ "level" => node.name[1].to_i
36
36
  }
37
37
  }
38
- when 'p', 'div'
38
+ when "p", "div"
39
39
  # Add any accumulated text first
40
40
  if current_text.present?
41
41
  blocks << create_paragraph_block(current_text)
42
- current_text = ''
42
+ current_text = ""
43
43
  end
44
44
 
45
- if node.name == 'div'
45
+ if node.name == "div"
46
46
  # Process div children separately
47
47
  node.children.each do |child|
48
48
  case child.name
49
- when 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'
49
+ when "h1", "h2", "h3", "h4", "h5", "h6"
50
50
  blocks << {
51
- 'type' => 'header',
52
- 'data' => {
53
- 'text' => child.text.strip,
54
- 'level' => child.name[1].to_i
51
+ "type" => "header",
52
+ "data" => {
53
+ "text" => child.text.strip,
54
+ "level" => child.name[1].to_i
55
55
  }
56
56
  }
57
- when 'p'
57
+ when "p"
58
58
  text = process_inline_elements(child)
59
59
  paragraphs = text.split(%r{<br\s*/?>\s*<br\s*/?>}).map(&:strip)
60
60
  paragraphs.each do |paragraph|
61
61
  blocks << create_paragraph_block(paragraph) if paragraph.present?
62
62
  end
63
- when 'ul', 'ol'
64
- items = child.css('li').map { |li| process_inline_elements(li) }
63
+ when "ul", "ol"
64
+ items = child.css("li").map { |li| process_inline_elements(li) }
65
65
  next if items.empty?
66
66
 
67
67
  blocks << {
68
- 'type' => 'list',
69
- 'data' => {
70
- 'style' => child.name == 'ul' ? 'unordered' : 'ordered',
71
- 'items' => items
68
+ "type" => "list",
69
+ "data" => {
70
+ "style" => (child.name == "ul") ? "unordered" : "ordered",
71
+ "items" => items
72
72
  }
73
73
  }
74
- when 'blockquote'
74
+ when "blockquote"
75
75
  blocks << {
76
- 'type' => 'quote',
77
- 'data' => {
78
- 'text' => process_inline_elements(child),
79
- 'caption' => '',
80
- 'alignment' => 'left'
76
+ "type" => "quote",
77
+ "data" => {
78
+ "text" => process_inline_elements(child),
79
+ "caption" => "",
80
+ "alignment" => "left"
81
81
  }
82
82
  }
83
- when 'text'
83
+ when "text"
84
84
  text = child.text.strip
85
85
  current_text += text if text.present?
86
86
  end
@@ -93,41 +93,41 @@ module Panda
93
93
  blocks << create_paragraph_block(paragraph) if paragraph.present?
94
94
  end
95
95
  end
96
- when 'br'
96
+ when "br"
97
97
  current_text += "\n\n"
98
- when 'text'
98
+ when "text"
99
99
  text = node.text.strip
100
100
  current_text += text if text.present?
101
- when 'ul', 'ol'
101
+ when "ul", "ol"
102
102
  # Add any accumulated text first
103
103
  if current_text.present?
104
104
  blocks << create_paragraph_block(current_text)
105
- current_text = ''
105
+ current_text = ""
106
106
  end
107
107
 
108
- items = node.css('li').map { |li| process_inline_elements(li) }
108
+ items = node.css("li").map { |li| process_inline_elements(li) }
109
109
  next if items.empty?
110
110
 
111
111
  blocks << {
112
- 'type' => 'list',
113
- 'data' => {
114
- 'style' => node.name == 'ul' ? 'unordered' : 'ordered',
115
- 'items' => items
112
+ "type" => "list",
113
+ "data" => {
114
+ "style" => (node.name == "ul") ? "unordered" : "ordered",
115
+ "items" => items
116
116
  }
117
117
  }
118
- when 'blockquote'
118
+ when "blockquote"
119
119
  # Add any accumulated text first
120
120
  if current_text.present?
121
121
  blocks << create_paragraph_block(current_text)
122
- current_text = ''
122
+ current_text = ""
123
123
  end
124
124
 
125
125
  blocks << {
126
- 'type' => 'quote',
127
- 'data' => {
128
- 'text' => process_inline_elements(node),
129
- 'caption' => '',
130
- 'alignment' => 'left'
126
+ "type" => "quote",
127
+ "data" => {
128
+ "text" => process_inline_elements(node),
129
+ "caption" => "",
130
+ "alignment" => "left"
131
131
  }
132
132
  }
133
133
  end
@@ -138,11 +138,11 @@ module Panda
138
138
 
139
139
  # Return the complete EditorJS structure
140
140
  {
141
- 'time' => Time.current.to_i * 1000,
142
- 'blocks' => blocks,
143
- 'version' => '2.28.2'
141
+ "time" => Time.current.to_i * 1000,
142
+ "blocks" => blocks,
143
+ "version" => "2.28.2"
144
144
  }
145
- rescue StandardError => e
145
+ rescue => e
146
146
  Rails.logger.error "HTML to EditorJS conversion failed: #{e.message}"
147
147
  Rails.logger.error e.backtrace.join("\n")
148
148
  raise ConversionError, "Failed to convert HTML to EditorJS format: #{e.message}"
@@ -151,41 +151,41 @@ module Panda
151
151
 
152
152
  def self.create_paragraph_block(text)
153
153
  {
154
- 'type' => 'paragraph',
155
- 'data' => {
156
- 'text' => text.strip
154
+ "type" => "paragraph",
155
+ "data" => {
156
+ "text" => text.strip
157
157
  }
158
158
  }
159
159
  end
160
160
 
161
161
  def self.process_inline_elements(node)
162
- result = ''
162
+ result = ""
163
163
  node.children.each do |child|
164
164
  case child.name
165
- when 'br'
166
- result += '<br>'
167
- when 'text'
165
+ when "br"
166
+ result += "<br>"
167
+ when "text"
168
168
  result += child.text
169
- when 'strong', 'b'
169
+ when "strong", "b"
170
170
  result += "<b>#{child.text}</b>"
171
- when 'em', 'i'
171
+ when "em", "i"
172
172
  result += "<i>#{child.text}</i>"
173
- when 'a'
174
- href = child['href']
173
+ when "a"
174
+ href = child["href"]
175
175
  text = child.text.strip
176
176
  # Handle email links specially
177
- if href&.start_with?('mailto:')
178
- email = href.sub('mailto:', '')
177
+ if href&.start_with?("mailto:")
178
+ email = href.sub("mailto:", "")
179
179
  result += "<a href=\"mailto:#{email}\">#{text}</a>"
180
180
  else
181
181
  result += "<a href=\"#{href}\">#{text}</a>"
182
182
  end
183
183
  else
184
184
  result += if child.text?
185
- child.text
186
- else
187
- child.to_html
188
- end
185
+ child.text
186
+ else
187
+ child.to_html
188
+ end
189
189
  end
190
190
  end
191
191
  result.strip
@@ -0,0 +1,120 @@
1
+ /* Base content styles, where .codex-editor applies them to the Panda editor too */
2
+ @layer components {
3
+ .codex-editor__redactor .ce-block .ce-block__content {
4
+ @apply text-base font-normal font-sans text-dark leading-[1.6] space-y-[1.6rem];
5
+
6
+ h1.ce-header {
7
+ @apply text-3xl md:text-4xl font-semibold font-sans text-[#104071] leading-[1.2] max-w-[85ch];
8
+ }
9
+
10
+ h2.ce-header {
11
+ @apply text-2xl font-medium font-sans text-[#104071] leading-[1.3] mb-4 mt-8 max-w-[85ch];
12
+ }
13
+
14
+ h3.ce-header {
15
+ @apply text-xl font-normal font-sans text-[#104071] leading-[1.3] mb-4 mt-6 max-w-[85ch];
16
+ }
17
+
18
+ p,
19
+ li {
20
+ @apply leading-[1.6] tracking-wide max-w-[85ch];
21
+
22
+ a {
23
+ @apply text-[#1A9597] underline underline-offset-2 hover:text-[#158486] focus:outline-2 focus:outline-offset-2 focus:outline-[#1A9597];
24
+ }
25
+
26
+ strong,
27
+ b {
28
+ @apply font-semibold;
29
+ }
30
+ }
31
+
32
+ p {
33
+ @apply mb-4;
34
+ }
35
+
36
+ .cdx-quote {
37
+ @apply bg-[#eef0f3] border-l-inactive border-l-8 p-6 mb-4;
38
+
39
+ .cdx-quote__caption {
40
+ @apply block ml-6 mt-2 text-sm text-dark;
41
+ }
42
+
43
+ .cdx-quote__text {
44
+ quotes: "\201C" "\201D" "\2018" "\2019";
45
+ @apply pl-6;
46
+
47
+ &:before {
48
+ @apply -ml-8 mr-2 text-dark text-6xl leading-4 align-text-bottom font-serif;
49
+ content: open-quote;
50
+ }
51
+
52
+ p {
53
+ @apply inline italic text-lg;
54
+ }
55
+ }
56
+ }
57
+
58
+ .cdx-list {
59
+ @apply mb-4 pl-6;
60
+
61
+ &--ordered {
62
+ @apply list-decimal;
63
+ }
64
+
65
+ &--unordered {
66
+ @apply list-disc;
67
+ }
68
+
69
+ .cdx-list {
70
+ @apply mt-2 mb-0;
71
+ }
72
+
73
+ .cdx-list__item {
74
+ @apply mb-2 pl-2;
75
+ }
76
+ }
77
+
78
+ .cdx-nested-list {
79
+ @apply mb-4 pl-6;
80
+
81
+ &--ordered {
82
+ @apply list-decimal;
83
+ }
84
+
85
+ &--unordered {
86
+ @apply list-disc;
87
+ }
88
+
89
+ .cdx-nested-list {
90
+ @apply mt-2 mb-0;
91
+ }
92
+
93
+ .cdx-nested-list__item {
94
+ @apply mb-2 pl-2;
95
+ }
96
+ }
97
+
98
+ .cdx-table {
99
+ @apply w-full border-collapse border-2 border-dark my-6;
100
+
101
+ &__head {
102
+ @apply font-semibold border-dark border-r-2 p-3 bg-light;
103
+ }
104
+
105
+ &__row {
106
+ @apply border-dark border-b-2;
107
+ }
108
+
109
+ &__cell {
110
+ @apply border-dark border-r-2 p-3;
111
+ }
112
+ }
113
+
114
+ .cdx-embed {
115
+ iframe {
116
+ @apply w-full border-none;
117
+ }
118
+ }
119
+ }
120
+ }
data/config/importmap.rb CHANGED
@@ -2,14 +2,25 @@
2
2
 
3
3
  # Pin npm packages by running ./bin/importmap
4
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'
5
+ # Entry points (required by panda_core_javascript helper)
6
+ # These must use absolute paths to work with JavaScriptMiddleware
7
+ pin "panda/editor/application", to: "/panda/editor/application.js", preload: true
8
+ pin "panda/editor/controllers/index", to: "/panda/editor/controllers/index.js"
9
+
10
+ # Individual modules used by panda-cms controllers
11
+ pin "panda/editor/editor_js_config", to: "/panda/editor/editor_js_config.js"
12
+ pin "panda/editor/editor_js_initializer", to: "/panda/editor/editor_js_initializer.js"
13
+ pin "panda/editor/resource_loader", to: "/panda/editor/resource_loader.js"
14
+ pin "panda/editor/plain_text_editor", to: "/panda/editor/plain_text_editor.js"
15
+ pin "panda/editor/css_extractor", to: "/panda/editor/css_extractor.js"
16
+ pin "panda/editor/rich_text_editor", to: "/panda/editor/rich_text_editor.js"
17
+
18
+ # EditorJS Core and plugins (from esm.sh - better ES module handling than jsdelivr)
19
+ pin "@editorjs/editorjs", to: "https://esm.sh/@editorjs/editorjs@2.28.2"
20
+ pin "@editorjs/paragraph", to: "https://esm.sh/@editorjs/paragraph@2.11.3"
21
+ pin "@editorjs/header", to: "https://esm.sh/@editorjs/header@2.8.1"
22
+ pin "@editorjs/nested-list", to: "https://esm.sh/@editorjs/nested-list@1.4.2"
23
+ pin "@editorjs/quote", to: "https://esm.sh/@editorjs/quote@2.6.0"
24
+ pin "@editorjs/simple-image", to: "https://esm.sh/@editorjs/simple-image@1.6.0"
25
+ pin "@editorjs/table", to: "https://esm.sh/@editorjs/table@2.3.0"
26
+ pin "@editorjs/embed", to: "https://esm.sh/@editorjs/embed@2.7.0"
data/docs/FOOTNOTES.md CHANGED
@@ -29,6 +29,7 @@ The footnote system consists of three main components:
29
29
  - 📍 **Position-based injection**: Place footnote markers at any character position
30
30
  - 🎨 **Collapsible UI**: Clean, accessible sources section
31
31
  - 🔗 **Bidirectional links**: Navigate between citations and sources
32
+ - 💬 **Hover tooltips**: Preview footnote content without scrolling to sources
32
33
 
33
34
  ## How It Works
34
35
 
@@ -181,10 +182,17 @@ Both paragraphs will reference the same footnote number, and the source will app
181
182
 
182
183
  ### Inline Markers
183
184
 
185
+ Footnote markers include native browser tooltips and data attributes for custom tooltip implementations:
186
+
184
187
  ```html
185
- <p>Climate change has accelerated significantly since 1980<sup id="fnref:1"><a href="#fn:1" class="footnote">1</a></sup></p>
188
+ <p>Climate change has accelerated significantly since 1980<sup id="fnref:1" class="footnote-ref" data-footnote-content="IPCC. (2023). Climate Change 2023: Synthesis Report." title="IPCC. (2023). Climate Change 2023: Synthesis Report."><a href="#fn:1" class="footnote">1</a></sup></p>
186
189
  ```
187
190
 
191
+ **Tooltip Attributes:**
192
+ - `class="footnote-ref"` - Identifies footnote markers for styling
193
+ - `data-footnote-content` - Contains processed footnote content (with markdown/HTML if enabled) for custom tooltips
194
+ - `title` - Contains plain text version for native browser tooltips on hover
195
+
188
196
  ### Sources Section
189
197
 
190
198
  ```html
@@ -442,6 +450,91 @@ def generate_cached_content
442
450
  end
443
451
  ```
444
452
 
453
+ ## Tooltips
454
+
455
+ Footnote markers automatically include tooltip support through two mechanisms:
456
+
457
+ ### Native Browser Tooltips
458
+
459
+ The `title` attribute provides instant, zero-JavaScript tooltips:
460
+
461
+ ```html
462
+ <sup title="IPCC. (2023). Climate Change 2023: Synthesis Report.">
463
+ <a href="#fn:1" class="footnote">1</a>
464
+ </sup>
465
+ ```
466
+
467
+ This works immediately in all browsers with no additional code required. However, native tooltips have limitations:
468
+ - Cannot contain HTML formatting
469
+ - Limited styling options
470
+ - Inconsistent behavior across browsers
471
+
472
+ ### Custom Tooltips
473
+
474
+ For richer tooltips, use the `data-footnote-content` attribute with your preferred tooltip library:
475
+
476
+ **With Tippy.js:**
477
+
478
+ ```javascript
479
+ import tippy from 'tippy.js'
480
+ import 'tippy.js/dist/tippy.css'
481
+
482
+ // Initialize tooltips for all footnote markers
483
+ tippy('[data-footnote-content]', {
484
+ content: (reference) => reference.getAttribute('data-footnote-content'),
485
+ allowHTML: true,
486
+ theme: 'light',
487
+ placement: 'top',
488
+ maxWidth: 400
489
+ })
490
+ ```
491
+
492
+ **With Bootstrap:**
493
+
494
+ ```javascript
495
+ // Initialize Bootstrap tooltips
496
+ document.querySelectorAll('[data-footnote-content]').forEach(element => {
497
+ new bootstrap.Tooltip(element, {
498
+ title: element.getAttribute('data-footnote-content'),
499
+ html: true,
500
+ placement: 'top'
501
+ })
502
+ })
503
+ ```
504
+
505
+ **With Custom CSS Tooltips:**
506
+
507
+ ```css
508
+ /* Pure CSS tooltip */
509
+ .footnote-ref {
510
+ position: relative;
511
+ }
512
+
513
+ .footnote-ref::after {
514
+ content: attr(data-footnote-content);
515
+ position: absolute;
516
+ bottom: 100%;
517
+ left: 50%;
518
+ transform: translateX(-50%);
519
+ padding: 0.5rem;
520
+ background: #333;
521
+ color: white;
522
+ border-radius: 0.25rem;
523
+ font-size: 0.875rem;
524
+ white-space: nowrap;
525
+ opacity: 0;
526
+ pointer-events: none;
527
+ transition: opacity 0.2s;
528
+ z-index: 1000;
529
+ }
530
+
531
+ .footnote-ref:hover::after {
532
+ opacity: 1;
533
+ }
534
+ ```
535
+
536
+ **Security Note:** The `data-footnote-content` attribute is properly HTML-escaped to prevent XSS attacks. When using `allowHTML: true` with tooltip libraries, the content is safe because special characters are already escaped.
537
+
445
538
  ## CSS Styling
446
539
 
447
540
  The rendered HTML includes Tailwind CSS classes. You can customize the appearance:
@@ -621,11 +714,11 @@ bundle exec rspec spec/lib/panda/editor/renderer_spec.rb
621
714
 
622
715
  ## Future Enhancements
623
716
 
624
- Potential improvements for future versions:
717
+ See GitHub issue [#2](https://github.com/tastybamboo/panda-editor/issues/2) for planned improvements including:
625
718
 
626
719
  - [ ] Support for footnotes in other block types (headers, quotes, etc.)
627
720
  - [x] Rich text formatting within footnote content (implemented via markdown support)
628
- - [ ] Footnote tooltips on hover
721
+ - [x] Footnote tooltips on hover (implemented with native browser tooltips and custom tooltip data attributes)
629
722
  - [ ] Customizable footnote markers (*, †, ‡, etc.)
630
723
  - [ ] Export footnotes to bibliography formats (BibTeX, etc.)
631
724
  - [ ] Footnote management UI in EditorJS
data/lefthook.yml ADDED
@@ -0,0 +1,16 @@
1
+ ---
2
+ assert_lefthook_installed: true
3
+ colors: true
4
+ pre-commit:
5
+ parallel: true
6
+ jobs:
7
+ - name: standardrb
8
+ run: bundle exec standardrb
9
+ glob: "*.rb"
10
+ stage_fixed: true
11
+
12
+ pre-push:
13
+ parallel: true
14
+ jobs:
15
+ - name: standardrb
16
+ run: bundle exec standardrb