panda-editor 0.2.1 → 0.4.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 +4 -4
- data/CHANGELOG.md +29 -0
- data/README.md +125 -0
- data/app/javascript/panda/editor/application.js +8 -0
- data/app/javascript/panda/editor/editor_js_config.js +28 -1
- data/app/javascript/panda/editor/editor_js_initializer.js +4 -1
- data/app/javascript/panda/editor/rich_text_editor.js +6 -1
- data/app/javascript/panda/editor/tools/footnote_tool.js +392 -0
- data/app/javascript/panda/editor/tools/paragraph_with_footnotes.js +280 -0
- data/docs/FOOTNOTES.md +640 -0
- data/lib/panda/editor/blocks/paragraph.rb +38 -0
- data/lib/panda/editor/content.rb +4 -2
- data/lib/panda/editor/engine.rb +2 -6
- data/lib/panda/editor/footnote_registry.rb +137 -0
- data/lib/panda/editor/renderer.rb +18 -1
- data/lib/panda/editor/version.rb +1 -1
- data/lib/panda/editor.rb +11 -0
- data/panda-editor.gemspec +4 -2
- data/test_footnotes_standalone.html +957 -0
- metadata +38 -4
data/docs/FOOTNOTES.md
ADDED
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
# Footnotes in Panda Editor
|
|
2
|
+
|
|
3
|
+
Panda Editor provides a powerful footnote system that allows you to add inline citations and references to your content. Footnotes are automatically collected, numbered, and rendered in a collapsible "Sources/References" section at the end of your document.
|
|
4
|
+
|
|
5
|
+
## Table of Contents
|
|
6
|
+
|
|
7
|
+
- [Overview](#overview)
|
|
8
|
+
- [How It Works](#how-it-works)
|
|
9
|
+
- [JSON Structure](#json-structure)
|
|
10
|
+
- [Field Reference](#field-reference)
|
|
11
|
+
- [Rendered Output](#rendered-output)
|
|
12
|
+
- [Frontend Integration](#frontend-integration)
|
|
13
|
+
- [Advanced Features](#advanced-features)
|
|
14
|
+
- [CSS Styling](#css-styling)
|
|
15
|
+
- [Examples](#examples)
|
|
16
|
+
|
|
17
|
+
## Overview
|
|
18
|
+
|
|
19
|
+
The footnote system consists of three main components:
|
|
20
|
+
|
|
21
|
+
1. **Paragraph Block**: Accepts footnote data within paragraph blocks and injects inline markers
|
|
22
|
+
2. **FootnoteRegistry**: Collects and numbers footnotes across the entire document
|
|
23
|
+
3. **Renderer**: Coordinates footnote processing and generates the sources section
|
|
24
|
+
|
|
25
|
+
### Key Features
|
|
26
|
+
|
|
27
|
+
- ✨ **Automatic numbering**: Sequential numbering across the entire document
|
|
28
|
+
- 🔄 **De-duplication**: Same source cited multiple times uses the same footnote number
|
|
29
|
+
- 📍 **Position-based injection**: Place footnote markers at any character position
|
|
30
|
+
- 🎨 **Collapsible UI**: Clean, accessible sources section
|
|
31
|
+
- 🔗 **Bidirectional links**: Navigate between citations and sources
|
|
32
|
+
|
|
33
|
+
## How It Works
|
|
34
|
+
|
|
35
|
+
### Architecture
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
┌─────────────────────────────────────────────────────────┐
|
|
39
|
+
│ EditorJS JSON │
|
|
40
|
+
│ (Contains blocks with embedded footnote data) │
|
|
41
|
+
└─────────────────────┬───────────────────────────────────┘
|
|
42
|
+
│
|
|
43
|
+
↓
|
|
44
|
+
┌─────────────────────────────────────────────────────────┐
|
|
45
|
+
│ Renderer │
|
|
46
|
+
│ - Creates FootnoteRegistry │
|
|
47
|
+
│ - Passes registry to all blocks via options │
|
|
48
|
+
└─────────────────────┬───────────────────────────────────┘
|
|
49
|
+
│
|
|
50
|
+
↓
|
|
51
|
+
┌─────────────────────────────────────────────────────────┐
|
|
52
|
+
│ Paragraph Block │
|
|
53
|
+
│ - Processes footnotes array │
|
|
54
|
+
│ - Registers each footnote with registry │
|
|
55
|
+
│ - Injects <sup> markers at specified positions │
|
|
56
|
+
└─────────────────────┬───────────────────────────────────┘
|
|
57
|
+
│
|
|
58
|
+
↓
|
|
59
|
+
┌─────────────────────────────────────────────────────────┐
|
|
60
|
+
│ FootnoteRegistry │
|
|
61
|
+
│ - Assigns sequential numbers │
|
|
62
|
+
│ - Tracks footnotes by ID for de-duplication │
|
|
63
|
+
│ - Generates sources section HTML │
|
|
64
|
+
└─────────────────────────────────────────────────────────┘
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Processing Flow
|
|
68
|
+
|
|
69
|
+
1. **Initialization**: Renderer creates a `FootnoteRegistry` instance
|
|
70
|
+
2. **Block Rendering**: Each paragraph block processes its footnotes:
|
|
71
|
+
- Sorts footnotes by position (descending) to avoid position shifts
|
|
72
|
+
- Registers each footnote with the registry
|
|
73
|
+
- Receives a footnote number back
|
|
74
|
+
- Injects superscript marker at the specified position
|
|
75
|
+
3. **Sources Generation**: After all blocks are rendered, the renderer:
|
|
76
|
+
- Checks if any footnotes were collected
|
|
77
|
+
- Appends the sources section if footnotes exist
|
|
78
|
+
|
|
79
|
+
## JSON Structure
|
|
80
|
+
|
|
81
|
+
Add footnotes to any paragraph block in your EditorJS JSON:
|
|
82
|
+
|
|
83
|
+
```json
|
|
84
|
+
{
|
|
85
|
+
"type": "paragraph",
|
|
86
|
+
"data": {
|
|
87
|
+
"text": "Climate change has accelerated significantly since 1980",
|
|
88
|
+
"footnotes": [
|
|
89
|
+
{
|
|
90
|
+
"id": "fn-uuid-123",
|
|
91
|
+
"content": "IPCC. (2023). Climate Change 2023: Synthesis Report.",
|
|
92
|
+
"position": 55
|
|
93
|
+
}
|
|
94
|
+
]
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Multiple Footnotes
|
|
100
|
+
|
|
101
|
+
```json
|
|
102
|
+
{
|
|
103
|
+
"type": "paragraph",
|
|
104
|
+
"data": {
|
|
105
|
+
"text": "Global temperature has risen 1.1°C since pre-industrial times",
|
|
106
|
+
"footnotes": [
|
|
107
|
+
{
|
|
108
|
+
"id": "fn-uuid-456",
|
|
109
|
+
"content": "NASA. (2023). Global Climate Change: Vital Signs.",
|
|
110
|
+
"position": 35
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
"id": "fn-uuid-789",
|
|
114
|
+
"content": "NOAA. (2023). State of the Climate Report.",
|
|
115
|
+
"position": 62
|
|
116
|
+
}
|
|
117
|
+
]
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Reusing Sources
|
|
123
|
+
|
|
124
|
+
To cite the same source multiple times, use the same `id`:
|
|
125
|
+
|
|
126
|
+
```json
|
|
127
|
+
{
|
|
128
|
+
"blocks": [
|
|
129
|
+
{
|
|
130
|
+
"type": "paragraph",
|
|
131
|
+
"data": {
|
|
132
|
+
"text": "First mention of the study",
|
|
133
|
+
"footnotes": [{
|
|
134
|
+
"id": "fn-study-2023",
|
|
135
|
+
"content": "Smith et al. (2023). Important Study.",
|
|
136
|
+
"position": 26
|
|
137
|
+
}]
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
"type": "paragraph",
|
|
142
|
+
"data": {
|
|
143
|
+
"text": "Second mention of the same study",
|
|
144
|
+
"footnotes": [{
|
|
145
|
+
"id": "fn-study-2023",
|
|
146
|
+
"content": "Smith et al. (2023). Important Study.",
|
|
147
|
+
"position": 33
|
|
148
|
+
}]
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
]
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Both paragraphs will reference the same footnote number, and the source will appear only once in the sources section.
|
|
156
|
+
|
|
157
|
+
## Field Reference
|
|
158
|
+
|
|
159
|
+
### `id` (required)
|
|
160
|
+
|
|
161
|
+
- **Type**: String
|
|
162
|
+
- **Purpose**: Unique identifier for the footnote
|
|
163
|
+
- **Best Practice**: Use UUIDs or descriptive IDs like `fn-study-name-year`
|
|
164
|
+
- **De-duplication**: Footnotes with the same `id` will share the same number
|
|
165
|
+
|
|
166
|
+
### `content` (required)
|
|
167
|
+
|
|
168
|
+
- **Type**: String
|
|
169
|
+
- **Purpose**: The citation or reference text
|
|
170
|
+
- **HTML Support**: Basic HTML tags are sanitized and allowed
|
|
171
|
+
- **Best Practice**: Use standard citation formats (APA, MLA, etc.)
|
|
172
|
+
|
|
173
|
+
### `position` (required)
|
|
174
|
+
|
|
175
|
+
- **Type**: Integer
|
|
176
|
+
- **Purpose**: Character position where the footnote marker should be inserted
|
|
177
|
+
- **Zero-indexed**: Position 0 is before the first character
|
|
178
|
+
- **Validation**: Must be between 0 and text length (inclusive)
|
|
179
|
+
|
|
180
|
+
## Rendered Output
|
|
181
|
+
|
|
182
|
+
### Inline Markers
|
|
183
|
+
|
|
184
|
+
```html
|
|
185
|
+
<p>Climate change has accelerated significantly since 1980<sup id="fnref:1"><a href="#fn:1" class="footnote">1</a></sup></p>
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### Sources Section
|
|
189
|
+
|
|
190
|
+
```html
|
|
191
|
+
<div class="mx-6 lg:mx-8 mt-4 mb-8">
|
|
192
|
+
<div class="footnotes-section bg-gray-50 rounded-lg overflow-hidden">
|
|
193
|
+
<button class="footnotes-header w-full px-4 py-3 flex items-center justify-between cursor-pointer hover:bg-gray-100 transition-colors"
|
|
194
|
+
data-footnotes-target="toggle"
|
|
195
|
+
data-action="click->footnotes#toggle">
|
|
196
|
+
<h3 class="text-sm font-unbounded font-medium text-gray-900 m-0">Sources/References</h3>
|
|
197
|
+
<svg class="footnotes-chevron w-5 h-5 text-gray-600"
|
|
198
|
+
data-footnotes-target="chevron"
|
|
199
|
+
fill="none"
|
|
200
|
+
stroke="currentColor"
|
|
201
|
+
viewBox="0 0 24 24">
|
|
202
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
|
203
|
+
</svg>
|
|
204
|
+
</button>
|
|
205
|
+
<div class="footnotes-content" data-footnotes-target="content">
|
|
206
|
+
<ol class="footnotes text-sm text-gray-700 space-y-2 px-4 pb-3">
|
|
207
|
+
<li id="fn:1">
|
|
208
|
+
<p>
|
|
209
|
+
IPCC. (2023). Climate Change 2023: Synthesis Report.
|
|
210
|
+
<a href="#fnref:1" class="footnote-backref">↩</a>
|
|
211
|
+
</p>
|
|
212
|
+
</li>
|
|
213
|
+
</ol>
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
</div>
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
## Frontend Integration
|
|
220
|
+
|
|
221
|
+
The sources section includes data attributes designed for use with JavaScript frameworks like Stimulus:
|
|
222
|
+
|
|
223
|
+
### Data Attributes
|
|
224
|
+
|
|
225
|
+
- `data-footnotes-target="toggle"` - The clickable header button
|
|
226
|
+
- `data-footnotes-target="content"` - The collapsible content section
|
|
227
|
+
- `data-footnotes-target="chevron"` - The chevron icon for rotation animation
|
|
228
|
+
|
|
229
|
+
### Example Stimulus Controller
|
|
230
|
+
|
|
231
|
+
```javascript
|
|
232
|
+
import { Controller } from "@hotwired/stimulus"
|
|
233
|
+
|
|
234
|
+
export default class extends Controller {
|
|
235
|
+
static targets = ["toggle", "content", "chevron"]
|
|
236
|
+
|
|
237
|
+
connect() {
|
|
238
|
+
// Start collapsed
|
|
239
|
+
this.contentTarget.classList.add("hidden")
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
toggle(event) {
|
|
243
|
+
event.preventDefault()
|
|
244
|
+
|
|
245
|
+
const isHidden = this.contentTarget.classList.contains("hidden")
|
|
246
|
+
|
|
247
|
+
if (isHidden) {
|
|
248
|
+
this.contentTarget.classList.remove("hidden")
|
|
249
|
+
this.chevronTarget.style.transform = "rotate(180deg)"
|
|
250
|
+
} else {
|
|
251
|
+
this.contentTarget.classList.add("hidden")
|
|
252
|
+
this.chevronTarget.style.transform = "rotate(0deg)"
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
### Accessible Mode
|
|
259
|
+
|
|
260
|
+
The footnotes section is designed to be accessible:
|
|
261
|
+
|
|
262
|
+
- Semantic HTML (`<button>`, `<ol>`, proper heading levels)
|
|
263
|
+
- ARIA-ready structure (add `aria-expanded` as needed)
|
|
264
|
+
- Keyboard navigation (links and buttons are focusable)
|
|
265
|
+
- Clear visual hierarchy
|
|
266
|
+
|
|
267
|
+
## Advanced Features
|
|
268
|
+
|
|
269
|
+
### Position Calculation
|
|
270
|
+
|
|
271
|
+
When determining where to place a footnote marker, count characters from the beginning of the text:
|
|
272
|
+
|
|
273
|
+
```javascript
|
|
274
|
+
const text = "Hello world"
|
|
275
|
+
// 0123456789...
|
|
276
|
+
|
|
277
|
+
// To place marker after "Hello"
|
|
278
|
+
position = 5
|
|
279
|
+
|
|
280
|
+
// Result: "Hello<sup>1</sup> world"
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
### Handling HTML in Text
|
|
284
|
+
|
|
285
|
+
If your paragraph contains HTML tags, remember that `position` refers to the character position in the **rendered HTML string**:
|
|
286
|
+
|
|
287
|
+
```javascript
|
|
288
|
+
const text = "Text with <b>bold</b> formatting"
|
|
289
|
+
// Position 10 is after "Text with "
|
|
290
|
+
// Position 13 is inside the <b> tag (after "<b>")
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
Best practice: Calculate positions based on plain text, not HTML.
|
|
294
|
+
|
|
295
|
+
### Sorting and Insertion
|
|
296
|
+
|
|
297
|
+
The paragraph block sorts footnotes by position in **descending order** before insertion. This prevents position shifts:
|
|
298
|
+
|
|
299
|
+
```ruby
|
|
300
|
+
# Without sorting (wrong):
|
|
301
|
+
text = "Hello world"
|
|
302
|
+
insert at position 5: "Hello<sup>1</sup> world" # Now position 11 shifted!
|
|
303
|
+
insert at position 11: Error - position too far
|
|
304
|
+
|
|
305
|
+
# With sorting (correct):
|
|
306
|
+
text = "Hello world"
|
|
307
|
+
insert at position 11: "Hello world<sup>2</sup>"
|
|
308
|
+
insert at position 5: "Hello<sup>1</sup> world<sup>2</sup>"
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
### Markdown Support
|
|
312
|
+
|
|
313
|
+
The footnote system supports markdown formatting, allowing you to use rich text formatting in your citations.
|
|
314
|
+
|
|
315
|
+
**Enable markdown:**
|
|
316
|
+
|
|
317
|
+
```ruby
|
|
318
|
+
renderer = Panda::Editor::Renderer.new(content, markdown: true)
|
|
319
|
+
output = renderer.render
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
**Supported markdown features:**
|
|
323
|
+
|
|
324
|
+
- **Bold text** (`**bold**` or `__bold__`)
|
|
325
|
+
- *Italic text* (`*italic*` or `_italic_`)
|
|
326
|
+
- `Inline code` (`` `code` ``)
|
|
327
|
+
- ~~Strikethrough~~ (`~~text~~`)
|
|
328
|
+
- [Links](url) (`[text](url)`)
|
|
329
|
+
- Automatic URL linking
|
|
330
|
+
|
|
331
|
+
**Example:**
|
|
332
|
+
|
|
333
|
+
```ruby
|
|
334
|
+
content = {
|
|
335
|
+
"blocks" => [{
|
|
336
|
+
"type" => "paragraph",
|
|
337
|
+
"data" => {
|
|
338
|
+
"text" => "Research findings",
|
|
339
|
+
"footnotes" => [{
|
|
340
|
+
"id" => "fn-1",
|
|
341
|
+
"content" => "Smith, J. (2023). **Important study** on *ADHD treatment*. See https://example.com for details.",
|
|
342
|
+
"position" => 17
|
|
343
|
+
}]
|
|
344
|
+
}
|
|
345
|
+
}]
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
renderer = Panda::Editor::Renderer.new(content, markdown: true)
|
|
349
|
+
# Output will include: Smith, J. (2023). <strong>Important study</strong> on <em>ADHD treatment</em>. See <a href="https://example.com">https://example.com</a> for details.
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
**Important notes:**
|
|
353
|
+
|
|
354
|
+
- Markdown includes built-in URL autolinking, so you typically don't need `autolink_urls: true` when using markdown
|
|
355
|
+
- However, both options can be used together if needed - the custom autolink_urls will skip URLs that markdown already linked
|
|
356
|
+
- Markdown links are rendered with `target="_blank"` and `rel="noopener noreferrer"` for security
|
|
357
|
+
- Images are disabled in markdown footnotes for security
|
|
358
|
+
- HTML styles are stripped from markdown output
|
|
359
|
+
|
|
360
|
+
### Auto-linking URLs
|
|
361
|
+
|
|
362
|
+
The footnote system can automatically convert plain URLs in footnote content into clickable links when markdown is not enabled.
|
|
363
|
+
|
|
364
|
+
**Enable auto-linking:**
|
|
365
|
+
|
|
366
|
+
```ruby
|
|
367
|
+
renderer = Panda::Editor::Renderer.new(content, autolink_urls: true)
|
|
368
|
+
output = renderer.render
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
**How it works:**
|
|
372
|
+
|
|
373
|
+
Plain URLs in footnote content are detected and wrapped in `<a>` tags:
|
|
374
|
+
|
|
375
|
+
```ruby
|
|
376
|
+
# Input footnote content:
|
|
377
|
+
"Ward, J.H. & Curran, S. (2021). https://doi.org/10.1111/camh.12471"
|
|
378
|
+
|
|
379
|
+
# Rendered output:
|
|
380
|
+
"Ward, J.H. & Curran, S. (2021). <a href=\"https://doi.org/10.1111/camh.12471\" target=\"_blank\" rel=\"noopener noreferrer\">https://doi.org/10.1111/camh.12471</a>"
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
**Features:**
|
|
384
|
+
|
|
385
|
+
- **Supported protocols**: `http://`, `https://`, `ftp://`, and `www.` (automatically prefixed with `https://`)
|
|
386
|
+
- **Security attributes**: All links include `target="_blank"` and `rel="noopener noreferrer"`
|
|
387
|
+
- **Smart detection**: Won't double-link URLs already in `<a>` tags
|
|
388
|
+
- **Multiple URLs**: Handles multiple URLs in the same footnote
|
|
389
|
+
- **Punctuation handling**: Excludes trailing punctuation (periods, commas, etc.) from URLs
|
|
390
|
+
|
|
391
|
+
**Pattern matching:**
|
|
392
|
+
|
|
393
|
+
The auto-linker uses a sophisticated regex pattern that:
|
|
394
|
+
- Matches complete URLs without breaking on query parameters
|
|
395
|
+
- Avoids linking URLs that are already part of href attributes
|
|
396
|
+
- Handles complex URLs with paths, query strings, and fragments
|
|
397
|
+
|
|
398
|
+
**Example with multiple URLs:**
|
|
399
|
+
|
|
400
|
+
```ruby
|
|
401
|
+
content = {
|
|
402
|
+
"blocks" => [{
|
|
403
|
+
"type" => "paragraph",
|
|
404
|
+
"data" => {
|
|
405
|
+
"text" => "Research findings",
|
|
406
|
+
"footnotes" => [{
|
|
407
|
+
"id" => "fn-1",
|
|
408
|
+
"content" => "See https://example.com/study and https://doi.org/10.1234/example for details",
|
|
409
|
+
"position" => 17
|
|
410
|
+
}]
|
|
411
|
+
}
|
|
412
|
+
}]
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
renderer = Panda::Editor::Renderer.new(content, autolink_urls: true)
|
|
416
|
+
# Both URLs will be converted to clickable links
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
**Disabling auto-linking:**
|
|
420
|
+
|
|
421
|
+
By default, auto-linking is disabled. Only enable it when needed:
|
|
422
|
+
|
|
423
|
+
```ruby
|
|
424
|
+
# Auto-linking disabled (default)
|
|
425
|
+
renderer = Panda::Editor::Renderer.new(content)
|
|
426
|
+
|
|
427
|
+
# Auto-linking enabled
|
|
428
|
+
renderer = Panda::Editor::Renderer.new(content, autolink_urls: true)
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
**Use in Content Concern:**
|
|
432
|
+
|
|
433
|
+
For applications using the `Panda::Editor::Content` concern, enable auto-linking in the `generate_cached_content` method:
|
|
434
|
+
|
|
435
|
+
```ruby
|
|
436
|
+
def generate_cached_content
|
|
437
|
+
renderer_options = {autolink_urls: true}
|
|
438
|
+
|
|
439
|
+
if content.is_a?(Hash) && content["blocks"].present?
|
|
440
|
+
self.cached_content = Panda::Editor::Renderer.new(content, renderer_options).render
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
## CSS Styling
|
|
446
|
+
|
|
447
|
+
The rendered HTML includes Tailwind CSS classes. You can customize the appearance:
|
|
448
|
+
|
|
449
|
+
### Default Classes
|
|
450
|
+
|
|
451
|
+
```html
|
|
452
|
+
<!-- Container -->
|
|
453
|
+
<div class="mx-6 lg:mx-8 mt-4 mb-8">
|
|
454
|
+
|
|
455
|
+
<!-- Section wrapper -->
|
|
456
|
+
<div class="footnotes-section bg-gray-50 rounded-lg overflow-hidden">
|
|
457
|
+
|
|
458
|
+
<!-- Toggle button -->
|
|
459
|
+
<button class="footnotes-header w-full px-4 py-3 flex items-center justify-between cursor-pointer hover:bg-gray-100 transition-colors">
|
|
460
|
+
|
|
461
|
+
<!-- Header text -->
|
|
462
|
+
<h3 class="text-sm font-unbounded font-medium text-gray-900 m-0">
|
|
463
|
+
|
|
464
|
+
<!-- Content area -->
|
|
465
|
+
<div class="footnotes-content">
|
|
466
|
+
<ol class="footnotes text-sm text-gray-700 space-y-2 px-4 pb-3">
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
### Custom Styling
|
|
470
|
+
|
|
471
|
+
To customize the appearance, override these classes in your application CSS:
|
|
472
|
+
|
|
473
|
+
```css
|
|
474
|
+
/* Custom container spacing */
|
|
475
|
+
.footnotes-section {
|
|
476
|
+
@apply my-12;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/* Custom header styling */
|
|
480
|
+
.footnotes-header h3 {
|
|
481
|
+
@apply text-lg font-bold;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/* Custom footnote list styling */
|
|
485
|
+
.footnotes-content ol {
|
|
486
|
+
@apply text-base leading-relaxed;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/* Inline footnote markers */
|
|
490
|
+
sup.footnote a {
|
|
491
|
+
@apply text-blue-600 hover:text-blue-800;
|
|
492
|
+
}
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
## Examples
|
|
496
|
+
|
|
497
|
+
### Academic Citation
|
|
498
|
+
|
|
499
|
+
```json
|
|
500
|
+
{
|
|
501
|
+
"type": "paragraph",
|
|
502
|
+
"data": {
|
|
503
|
+
"text": "Research shows significant improvements in renewable energy efficiency",
|
|
504
|
+
"footnotes": [{
|
|
505
|
+
"id": "fn-smith-2023",
|
|
506
|
+
"content": "Smith, J., & Jones, M. (2023). Advances in solar panel technology. <em>Journal of Renewable Energy</em>, 45(2), 123-145.",
|
|
507
|
+
"position": 70
|
|
508
|
+
}]
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
### Web Resource
|
|
514
|
+
|
|
515
|
+
```json
|
|
516
|
+
{
|
|
517
|
+
"type": "paragraph",
|
|
518
|
+
"data": {
|
|
519
|
+
"text": "According to NASA, global sea levels have risen 8-9 inches since 1880",
|
|
520
|
+
"footnotes": [{
|
|
521
|
+
"id": "fn-nasa-2023",
|
|
522
|
+
"content": "NASA. (2023). <a href=\"https://climate.nasa.gov/vital-signs/sea-level/\" target=\"_blank\">Sea Level Change Data</a>. Retrieved October 26, 2025.",
|
|
523
|
+
"position": 70
|
|
524
|
+
}]
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
### Multiple Citations
|
|
530
|
+
|
|
531
|
+
```json
|
|
532
|
+
{
|
|
533
|
+
"type": "paragraph",
|
|
534
|
+
"data": {
|
|
535
|
+
"text": "Studies from 2022 and 2023 confirm these findings",
|
|
536
|
+
"footnotes": [
|
|
537
|
+
{
|
|
538
|
+
"id": "fn-study-2022",
|
|
539
|
+
"content": "Johnson, A. (2022). Early intervention strategies.",
|
|
540
|
+
"position": 18
|
|
541
|
+
},
|
|
542
|
+
{
|
|
543
|
+
"id": "fn-study-2023",
|
|
544
|
+
"content": "Williams, B. (2023). Long-term outcomes.",
|
|
545
|
+
"position": 31
|
|
546
|
+
}
|
|
547
|
+
]
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
## Testing
|
|
553
|
+
|
|
554
|
+
The footnote system includes comprehensive test coverage:
|
|
555
|
+
|
|
556
|
+
### Paragraph Block Tests
|
|
557
|
+
|
|
558
|
+
Located in `spec/lib/panda/editor/blocks/paragraph_spec.rb`:
|
|
559
|
+
|
|
560
|
+
- ✓ Injects footnote markers at correct positions
|
|
561
|
+
- ✓ Registers footnotes with the registry
|
|
562
|
+
- ✓ Handles multiple footnotes in correct order
|
|
563
|
+
- ✓ Returns same number for duplicate IDs
|
|
564
|
+
- ✓ Works without footnotes
|
|
565
|
+
|
|
566
|
+
### Renderer Tests
|
|
567
|
+
|
|
568
|
+
Located in `spec/lib/panda/editor/renderer_spec.rb`:
|
|
569
|
+
|
|
570
|
+
- ✓ Appends sources section when footnotes exist
|
|
571
|
+
- ✓ Does not append sources section when no footnotes
|
|
572
|
+
- ✓ Handles duplicate footnote IDs correctly
|
|
573
|
+
- ✓ Numbers footnotes sequentially across paragraphs
|
|
574
|
+
|
|
575
|
+
### Running Tests
|
|
576
|
+
|
|
577
|
+
```bash
|
|
578
|
+
cd /path/to/panda-editor
|
|
579
|
+
bundle exec rspec spec/lib/panda/editor/blocks/paragraph_spec.rb
|
|
580
|
+
bundle exec rspec spec/lib/panda/editor/renderer_spec.rb
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
## Troubleshooting
|
|
584
|
+
|
|
585
|
+
### Footnote marker not appearing
|
|
586
|
+
|
|
587
|
+
**Problem**: The superscript marker doesn't show up in the rendered HTML.
|
|
588
|
+
|
|
589
|
+
**Solutions**:
|
|
590
|
+
- Check that `position` is within the text length (0 to text.length)
|
|
591
|
+
- Verify the footnote has both `id` and `content` fields
|
|
592
|
+
- Ensure the `FootnoteRegistry` is passed in the options
|
|
593
|
+
|
|
594
|
+
### Wrong footnote number
|
|
595
|
+
|
|
596
|
+
**Problem**: Footnote shows number 2 when it should be 1.
|
|
597
|
+
|
|
598
|
+
**Solutions**:
|
|
599
|
+
- Check if another footnote is being registered first
|
|
600
|
+
- Verify footnote IDs are unique (unless intentionally reusing)
|
|
601
|
+
- Review the order of blocks in your JSON
|
|
602
|
+
|
|
603
|
+
### Sources section not appearing
|
|
604
|
+
|
|
605
|
+
**Problem**: Inline markers work but no sources section at bottom.
|
|
606
|
+
|
|
607
|
+
**Solutions**:
|
|
608
|
+
- Verify footnotes are actually being registered (check `FootnoteRegistry#any?`)
|
|
609
|
+
- Ensure the renderer is calling `render_sources_section`
|
|
610
|
+
- Check that blocks are receiving the footnote registry in options
|
|
611
|
+
|
|
612
|
+
### Position calculation off by one
|
|
613
|
+
|
|
614
|
+
**Problem**: Footnote appears one character before/after expected position.
|
|
615
|
+
|
|
616
|
+
**Solutions**:
|
|
617
|
+
- Remember positions are zero-indexed
|
|
618
|
+
- Position 0 is before the first character
|
|
619
|
+
- Position equal to text.length is after the last character
|
|
620
|
+
- Test with plain text first, then add HTML formatting
|
|
621
|
+
|
|
622
|
+
## Future Enhancements
|
|
623
|
+
|
|
624
|
+
Potential improvements for future versions:
|
|
625
|
+
|
|
626
|
+
- [ ] Support for footnotes in other block types (headers, quotes, etc.)
|
|
627
|
+
- [x] Rich text formatting within footnote content (implemented via markdown support)
|
|
628
|
+
- [ ] Footnote tooltips on hover
|
|
629
|
+
- [ ] Customizable footnote markers (*, †, ‡, etc.)
|
|
630
|
+
- [ ] Export footnotes to bibliography formats (BibTeX, etc.)
|
|
631
|
+
- [ ] Footnote management UI in EditorJS
|
|
632
|
+
- [ ] Smart position recalculation when text changes
|
|
633
|
+
|
|
634
|
+
## License
|
|
635
|
+
|
|
636
|
+
This documentation is part of Panda Editor, available under the BSD-3-Clause License.
|
|
637
|
+
|
|
638
|
+
## Contributing
|
|
639
|
+
|
|
640
|
+
Found a bug or have a suggestion? Please open an issue on GitHub at https://github.com/tastybamboo/panda-editor.
|
|
@@ -8,8 +8,46 @@ module Panda
|
|
|
8
8
|
content = sanitize(data["text"])
|
|
9
9
|
return "" if content.blank?
|
|
10
10
|
|
|
11
|
+
content = inject_footnotes(content) if data["footnotes"].present?
|
|
12
|
+
|
|
11
13
|
html_safe("<p>#{content}</p>")
|
|
12
14
|
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def inject_footnotes(text)
|
|
19
|
+
return text unless data["footnotes"].is_a?(Array)
|
|
20
|
+
|
|
21
|
+
# Sort footnotes by position in descending order to avoid position shifts
|
|
22
|
+
footnotes = data["footnotes"].sort_by { |fn| -fn["position"].to_i }
|
|
23
|
+
|
|
24
|
+
footnotes.each do |footnote|
|
|
25
|
+
position = footnote["position"].to_i
|
|
26
|
+
# Skip if position is beyond text length
|
|
27
|
+
next if position < 0 || position > text.length
|
|
28
|
+
|
|
29
|
+
# Register footnote with renderer's footnote registry
|
|
30
|
+
footnote_number = register_footnote(footnote)
|
|
31
|
+
next unless footnote_number
|
|
32
|
+
|
|
33
|
+
# Create footnote marker
|
|
34
|
+
marker = "<sup id=\"fnref:#{footnote_number}\"><a href=\"#fn:#{footnote_number}\" class=\"footnote\">#{footnote_number}</a></sup>"
|
|
35
|
+
|
|
36
|
+
# Insert marker at position
|
|
37
|
+
text = text.insert(position, marker)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
text
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def register_footnote(footnote)
|
|
44
|
+
return nil unless options[:footnote_registry]
|
|
45
|
+
|
|
46
|
+
options[:footnote_registry].add(
|
|
47
|
+
id: footnote["id"],
|
|
48
|
+
content: footnote["content"]
|
|
49
|
+
)
|
|
50
|
+
end
|
|
13
51
|
end
|
|
14
52
|
end
|
|
15
53
|
end
|
data/lib/panda/editor/content.rb
CHANGED
|
@@ -36,11 +36,13 @@ module Panda
|
|
|
36
36
|
end
|
|
37
37
|
|
|
38
38
|
def generate_cached_content
|
|
39
|
+
renderer_options = {autolink_urls: true}
|
|
40
|
+
|
|
39
41
|
if content.is_a?(String)
|
|
40
42
|
begin
|
|
41
43
|
parsed_content = JSON.parse(content)
|
|
42
44
|
self.cached_content = if parsed_content.is_a?(Hash) && parsed_content["blocks"].present?
|
|
43
|
-
Panda::Editor::Renderer.new(parsed_content).render
|
|
45
|
+
Panda::Editor::Renderer.new(parsed_content, renderer_options).render
|
|
44
46
|
else
|
|
45
47
|
content
|
|
46
48
|
end
|
|
@@ -50,7 +52,7 @@ module Panda
|
|
|
50
52
|
end
|
|
51
53
|
elsif content.is_a?(Hash) && content["blocks"].present?
|
|
52
54
|
# Process EditorJS content
|
|
53
|
-
self.cached_content = Panda::Editor::Renderer.new(content).render
|
|
55
|
+
self.cached_content = Panda::Editor::Renderer.new(content, renderer_options).render
|
|
54
56
|
else
|
|
55
57
|
# For any other case, store as is
|
|
56
58
|
self.cached_content = content.to_s
|