panda-editor 0.2.0 → 0.3.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 +18 -0
- data/README.md +101 -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 +591 -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 +95 -0
- data/lib/panda/editor/renderer.rb +17 -1
- data/lib/panda/editor/version.rb +1 -1
- data/lib/panda/editor.rb +11 -0
- data/panda-editor.gemspec +3 -2
- data/test_footnotes_standalone.html +957 -0
- metadata +28 -5
|
@@ -0,0 +1,957 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Panda Editor - Footnote Tool Test</title>
|
|
7
|
+
<style>
|
|
8
|
+
body {
|
|
9
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
10
|
+
max-width: 900px;
|
|
11
|
+
margin: 0 auto;
|
|
12
|
+
padding: 40px 20px;
|
|
13
|
+
background: #f9fafb;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
h1 {
|
|
17
|
+
color: #111827;
|
|
18
|
+
margin-bottom: 10px;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.subtitle {
|
|
22
|
+
color: #6b7280;
|
|
23
|
+
margin-bottom: 30px;
|
|
24
|
+
font-size: 14px;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.test-container {
|
|
28
|
+
background: white;
|
|
29
|
+
border-radius: 8px;
|
|
30
|
+
padding: 30px;
|
|
31
|
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
|
32
|
+
margin-bottom: 30px;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.section-title {
|
|
36
|
+
font-size: 18px;
|
|
37
|
+
font-weight: 600;
|
|
38
|
+
margin-bottom: 15px;
|
|
39
|
+
color: #374151;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
#editorjs {
|
|
43
|
+
border: 1px solid #e5e7eb;
|
|
44
|
+
border-radius: 6px;
|
|
45
|
+
min-height: 300px;
|
|
46
|
+
background: white;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.button-group {
|
|
50
|
+
display: flex;
|
|
51
|
+
gap: 10px;
|
|
52
|
+
margin-top: 20px;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
button {
|
|
56
|
+
padding: 10px 20px;
|
|
57
|
+
border: 1px solid #d1d5db;
|
|
58
|
+
border-radius: 6px;
|
|
59
|
+
background: white;
|
|
60
|
+
color: #374151;
|
|
61
|
+
font-size: 14px;
|
|
62
|
+
font-weight: 500;
|
|
63
|
+
cursor: pointer;
|
|
64
|
+
transition: all 0.2s;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
button:hover {
|
|
68
|
+
background: #f9fafb;
|
|
69
|
+
border-color: #9ca3af;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
button.primary {
|
|
73
|
+
background: #3b82f6;
|
|
74
|
+
color: white;
|
|
75
|
+
border-color: #3b82f6;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
button.primary:hover {
|
|
79
|
+
background: #2563eb;
|
|
80
|
+
border-color: #2563eb;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
#output {
|
|
84
|
+
background: #f9fafb;
|
|
85
|
+
border: 1px solid #e5e7eb;
|
|
86
|
+
border-radius: 6px;
|
|
87
|
+
padding: 20px;
|
|
88
|
+
font-family: 'Monaco', 'Courier New', monospace;
|
|
89
|
+
font-size: 12px;
|
|
90
|
+
line-height: 1.6;
|
|
91
|
+
overflow-x: auto;
|
|
92
|
+
white-space: pre-wrap;
|
|
93
|
+
word-wrap: break-word;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
#rendered {
|
|
97
|
+
background: white;
|
|
98
|
+
border: 1px solid #e5e7eb;
|
|
99
|
+
border-radius: 6px;
|
|
100
|
+
padding: 20px;
|
|
101
|
+
min-height: 100px;
|
|
102
|
+
line-height: 1.8;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.instructions {
|
|
106
|
+
background: #fef3c7;
|
|
107
|
+
border: 1px solid #fbbf24;
|
|
108
|
+
border-radius: 6px;
|
|
109
|
+
padding: 15px;
|
|
110
|
+
margin-bottom: 20px;
|
|
111
|
+
font-size: 14px;
|
|
112
|
+
line-height: 1.6;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.instructions h3 {
|
|
116
|
+
margin: 0 0 10px 0;
|
|
117
|
+
color: #92400e;
|
|
118
|
+
font-size: 16px;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.instructions ol {
|
|
122
|
+
margin: 10px 0 0 20px;
|
|
123
|
+
padding: 0;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.instructions li {
|
|
127
|
+
margin-bottom: 5px;
|
|
128
|
+
color: #78350f;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/* EditorJS Styles */
|
|
132
|
+
.codex-editor {
|
|
133
|
+
position: relative;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.codex-editor::before {
|
|
137
|
+
content: '';
|
|
138
|
+
position: absolute;
|
|
139
|
+
left: 0;
|
|
140
|
+
top: 0;
|
|
141
|
+
bottom: 0;
|
|
142
|
+
width: 65px;
|
|
143
|
+
margin-right: 5px;
|
|
144
|
+
background-color: #f9fafb;
|
|
145
|
+
border-right: 2px dashed #e5e7eb;
|
|
146
|
+
z-index: 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.ce-block {
|
|
150
|
+
padding-left: 70px;
|
|
151
|
+
position: relative;
|
|
152
|
+
min-height: 40px;
|
|
153
|
+
margin: 0;
|
|
154
|
+
padding-bottom: 1em;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.ce-block__content {
|
|
158
|
+
position: relative;
|
|
159
|
+
max-width: none;
|
|
160
|
+
margin: 0;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.ce-paragraph {
|
|
164
|
+
padding: 0;
|
|
165
|
+
line-height: 1.6;
|
|
166
|
+
min-height: 1.6em;
|
|
167
|
+
margin: 0;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/* Footnote marker styles */
|
|
171
|
+
.footnote-marker {
|
|
172
|
+
display: inline-block;
|
|
173
|
+
color: #3b82f6;
|
|
174
|
+
font-size: 0.75em;
|
|
175
|
+
font-weight: 600;
|
|
176
|
+
vertical-align: super;
|
|
177
|
+
cursor: pointer;
|
|
178
|
+
padding: 0 2px;
|
|
179
|
+
user-select: none;
|
|
180
|
+
margin-left: 1px;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.footnote-marker:hover {
|
|
184
|
+
color: #2563eb;
|
|
185
|
+
text-decoration: underline;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/* Toolbar styles */
|
|
189
|
+
.ce-toolbar {
|
|
190
|
+
left: 0 !important;
|
|
191
|
+
right: auto !important;
|
|
192
|
+
background: none !important;
|
|
193
|
+
position: absolute !important;
|
|
194
|
+
width: 65px !important;
|
|
195
|
+
height: 40px !important;
|
|
196
|
+
display: flex !important;
|
|
197
|
+
align-items: center !important;
|
|
198
|
+
justify-content: flex-start !important;
|
|
199
|
+
padding: 0 !important;
|
|
200
|
+
margin-left: -70px !important;
|
|
201
|
+
margin-top: -5px !important;
|
|
202
|
+
opacity: 1 !important;
|
|
203
|
+
visibility: visible !important;
|
|
204
|
+
pointer-events: all !important;
|
|
205
|
+
z-index: 2 !important;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.status-message {
|
|
209
|
+
padding: 12px;
|
|
210
|
+
border-radius: 6px;
|
|
211
|
+
margin-bottom: 15px;
|
|
212
|
+
font-size: 14px;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.status-message.info {
|
|
216
|
+
background: #dbeafe;
|
|
217
|
+
border: 1px solid #3b82f6;
|
|
218
|
+
color: #1e40af;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
.status-message.error {
|
|
222
|
+
background: #fee2e2;
|
|
223
|
+
border: 1px solid #ef4444;
|
|
224
|
+
color: #991b1b;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
.status-message.success {
|
|
228
|
+
background: #d1fae5;
|
|
229
|
+
border: 1px solid #10b981;
|
|
230
|
+
color: #065f46;
|
|
231
|
+
}
|
|
232
|
+
</style>
|
|
233
|
+
</head>
|
|
234
|
+
<body>
|
|
235
|
+
<h1>🦾 Panda Editor - Footnote Tool Test</h1>
|
|
236
|
+
<p class="subtitle">Testing the new EditorJS inline footnote tool with auto-generated IDs</p>
|
|
237
|
+
|
|
238
|
+
<div class="test-container">
|
|
239
|
+
<div id="status-area"></div>
|
|
240
|
+
|
|
241
|
+
<div class="instructions">
|
|
242
|
+
<h3>📋 How to Test</h3>
|
|
243
|
+
<ol>
|
|
244
|
+
<li>Wait for the editor to load (status message will appear)</li>
|
|
245
|
+
<li>Type some text in the editor below</li>
|
|
246
|
+
<li>Select a word or phrase where you want to add a footnote</li>
|
|
247
|
+
<li>Click the <strong>footnote button (fn)</strong> in the inline toolbar</li>
|
|
248
|
+
<li>Enter a citation in the modal (e.g., "Smith, J. (2023). Research Study.")</li>
|
|
249
|
+
<li>Click "Add Footnote" or press Cmd/Ctrl+Enter</li>
|
|
250
|
+
<li>You should see a blue <strong>numbered marker</strong> appear (1, 2, 3, etc.)</li>
|
|
251
|
+
<li>Try adding multiple footnotes - they automatically renumber!</li>
|
|
252
|
+
<li>Click "Get JSON Data" to see the footnote data structure</li>
|
|
253
|
+
</ol>
|
|
254
|
+
</div>
|
|
255
|
+
|
|
256
|
+
<h2 class="section-title">Editor</h2>
|
|
257
|
+
<div id="editorjs"></div>
|
|
258
|
+
|
|
259
|
+
<div class="button-group">
|
|
260
|
+
<button class="primary" onclick="saveData()">Get JSON Data</button>
|
|
261
|
+
<button onclick="clearEditor()">Clear Editor</button>
|
|
262
|
+
<button onclick="loadSample()">Load Sample with Footnotes</button>
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
|
|
266
|
+
<div class="test-container">
|
|
267
|
+
<h2 class="section-title">JSON Output</h2>
|
|
268
|
+
<div id="output">Click "Get JSON Data" to see the editor content with footnote data...</div>
|
|
269
|
+
</div>
|
|
270
|
+
|
|
271
|
+
<div class="test-container">
|
|
272
|
+
<h2 class="section-title">Rendered Preview (simulated)</h2>
|
|
273
|
+
<div id="rendered">The rendered HTML will appear here after you save...</div>
|
|
274
|
+
</div>
|
|
275
|
+
|
|
276
|
+
<!-- Load EditorJS and tools from CDN -->
|
|
277
|
+
<script src="https://cdn.jsdelivr.net/npm/@editorjs/editorjs@2.28.2"></script>
|
|
278
|
+
<script src="https://cdn.jsdelivr.net/npm/@editorjs/paragraph@2.11.3"></script>
|
|
279
|
+
<script src="https://cdn.jsdelivr.net/npm/@editorjs/header@2.8.1"></script>
|
|
280
|
+
|
|
281
|
+
<script>
|
|
282
|
+
// Status helper
|
|
283
|
+
function showStatus(message, type = 'info') {
|
|
284
|
+
const statusArea = document.getElementById('status-area');
|
|
285
|
+
statusArea.innerHTML = `<div class="status-message ${type}">${message}</div>`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
showStatus('⏳ Loading footnote tool...', 'info');
|
|
289
|
+
|
|
290
|
+
// NOTE: For this standalone test, we'll use a simplified footnote implementation
|
|
291
|
+
// The full implementation requires serving from a web server due to ES6 module restrictions
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Simplified FootnoteTool for testing
|
|
295
|
+
*/
|
|
296
|
+
class FootnoteTool {
|
|
297
|
+
static get isInline() {
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
static get title() {
|
|
302
|
+
return 'Footnote';
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
static get sanitize() {
|
|
306
|
+
return {
|
|
307
|
+
sup: {
|
|
308
|
+
class: 'footnote-marker',
|
|
309
|
+
'data-footnote-id': true,
|
|
310
|
+
'data-footnote-content': true
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
constructor({ api }) {
|
|
316
|
+
this.api = api;
|
|
317
|
+
this.button = null;
|
|
318
|
+
this.state = false;
|
|
319
|
+
|
|
320
|
+
this.CSS = {
|
|
321
|
+
button: 'ce-inline-tool',
|
|
322
|
+
buttonActive: 'ce-inline-tool--active',
|
|
323
|
+
buttonModifier: 'ce-inline-tool--footnote'
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
this.iconSVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
327
|
+
<text x="2" y="16" font-size="12" font-weight="bold" fill="currentColor">fn</text>
|
|
328
|
+
</svg>`;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
render() {
|
|
332
|
+
this.button = document.createElement('button');
|
|
333
|
+
this.button.type = 'button';
|
|
334
|
+
this.button.classList.add(this.CSS.button, this.CSS.buttonModifier);
|
|
335
|
+
this.button.innerHTML = this.iconSVG;
|
|
336
|
+
this.button.title = 'Add Footnote';
|
|
337
|
+
|
|
338
|
+
return this.button;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
surround(range) {
|
|
342
|
+
if (this.state) {
|
|
343
|
+
this.unwrap(range);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const position = this.getCaretPosition(range);
|
|
348
|
+
|
|
349
|
+
this.showFootnoteModal((content) => {
|
|
350
|
+
if (!content || content.trim() === '') {
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const footnoteId = this.generateFootnoteId();
|
|
355
|
+
this.wrap(range, footnoteId, content.trim());
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
wrap(range, footnoteId, content) {
|
|
360
|
+
const marker = document.createElement('sup');
|
|
361
|
+
marker.classList.add('footnote-marker');
|
|
362
|
+
marker.dataset.footnoteId = footnoteId;
|
|
363
|
+
marker.dataset.footnoteContent = content;
|
|
364
|
+
marker.contentEditable = false;
|
|
365
|
+
|
|
366
|
+
range.collapse(false);
|
|
367
|
+
range.insertNode(marker);
|
|
368
|
+
|
|
369
|
+
range.setStartAfter(marker);
|
|
370
|
+
range.collapse(true);
|
|
371
|
+
|
|
372
|
+
const selection = window.getSelection();
|
|
373
|
+
selection.removeAllRanges();
|
|
374
|
+
selection.addRange(range);
|
|
375
|
+
|
|
376
|
+
// Renumber all footnotes in the document
|
|
377
|
+
setTimeout(() => this.renumberAllFootnotes(), 0);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
unwrap(range) {
|
|
381
|
+
const marker = this.findMarkerInRange(range);
|
|
382
|
+
if (marker) {
|
|
383
|
+
marker.remove();
|
|
384
|
+
|
|
385
|
+
// Renumber remaining footnotes
|
|
386
|
+
setTimeout(() => this.renumberAllFootnotes(), 0);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
checkState(selection) {
|
|
391
|
+
if (!selection || !selection.anchorNode) {
|
|
392
|
+
this.state = false;
|
|
393
|
+
return false;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const marker = this.findMarkerInSelection(selection);
|
|
397
|
+
this.state = !!marker;
|
|
398
|
+
|
|
399
|
+
return this.state;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
findMarkerInSelection(selection) {
|
|
403
|
+
if (!selection.anchorNode) return null;
|
|
404
|
+
|
|
405
|
+
let node = selection.anchorNode;
|
|
406
|
+
|
|
407
|
+
while (node) {
|
|
408
|
+
if (node.nodeType === Node.ELEMENT_NODE &&
|
|
409
|
+
node.tagName === 'SUP' &&
|
|
410
|
+
node.classList.contains('footnote-marker')) {
|
|
411
|
+
return node;
|
|
412
|
+
}
|
|
413
|
+
node = node.parentNode;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
findMarkerInRange(range) {
|
|
420
|
+
const container = range.commonAncestorContainer;
|
|
421
|
+
|
|
422
|
+
if (container.nodeType === Node.ELEMENT_NODE &&
|
|
423
|
+
container.classList && container.classList.contains('footnote-marker')) {
|
|
424
|
+
return container;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const markers = container.querySelectorAll?.('.footnote-marker');
|
|
428
|
+
return markers?.[0] || null;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
getCaretPosition(range) {
|
|
432
|
+
const block = this.api.blocks.getBlockByIndex(this.api.blocks.getCurrentBlockIndex());
|
|
433
|
+
if (!block) return 0;
|
|
434
|
+
|
|
435
|
+
const blockElement = block.holder;
|
|
436
|
+
const contentElement = blockElement.querySelector('.ce-paragraph');
|
|
437
|
+
|
|
438
|
+
if (!contentElement) return 0;
|
|
439
|
+
|
|
440
|
+
const preCaretRange = range.cloneRange();
|
|
441
|
+
preCaretRange.selectNodeContents(contentElement);
|
|
442
|
+
preCaretRange.setEnd(range.endContainer, range.endOffset);
|
|
443
|
+
|
|
444
|
+
return preCaretRange.toString().length;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
generateFootnoteId() {
|
|
448
|
+
return `fn-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
renumberAllFootnotes() {
|
|
452
|
+
try {
|
|
453
|
+
const blocksCount = this.api.blocks.getBlocksCount();
|
|
454
|
+
let footnoteNumber = 0;
|
|
455
|
+
|
|
456
|
+
// Scan through all blocks in order
|
|
457
|
+
for (let i = 0; i < blocksCount; i++) {
|
|
458
|
+
const block = this.api.blocks.getBlockByIndex(i);
|
|
459
|
+
if (!block) continue;
|
|
460
|
+
|
|
461
|
+
const blockElement = block.holder;
|
|
462
|
+
const markers = blockElement.querySelectorAll('.footnote-marker');
|
|
463
|
+
|
|
464
|
+
// Update each marker in this block
|
|
465
|
+
markers.forEach(marker => {
|
|
466
|
+
footnoteNumber++;
|
|
467
|
+
marker.textContent = footnoteNumber.toString();
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
console.debug('[Footnote Tool] Renumbered', footnoteNumber, 'footnotes');
|
|
472
|
+
} catch (error) {
|
|
473
|
+
console.error('[Footnote Tool] Error renumbering footnotes:', error);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
showFootnoteModal(onSave) {
|
|
478
|
+
const overlay = document.createElement('div');
|
|
479
|
+
overlay.style.cssText = `
|
|
480
|
+
position: fixed;
|
|
481
|
+
top: 0;
|
|
482
|
+
left: 0;
|
|
483
|
+
right: 0;
|
|
484
|
+
bottom: 0;
|
|
485
|
+
background: rgba(0, 0, 0, 0.5);
|
|
486
|
+
display: flex;
|
|
487
|
+
align-items: center;
|
|
488
|
+
justify-content: center;
|
|
489
|
+
z-index: 10000;
|
|
490
|
+
`;
|
|
491
|
+
|
|
492
|
+
const modal = document.createElement('div');
|
|
493
|
+
modal.style.cssText = `
|
|
494
|
+
background: white;
|
|
495
|
+
border-radius: 8px;
|
|
496
|
+
padding: 24px;
|
|
497
|
+
max-width: 500px;
|
|
498
|
+
width: 90%;
|
|
499
|
+
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
500
|
+
`;
|
|
501
|
+
|
|
502
|
+
modal.innerHTML = `
|
|
503
|
+
<h3 style="margin: 0 0 16px 0; font-size: 18px; font-weight: 600;">Add Footnote</h3>
|
|
504
|
+
<div style="margin-bottom: 16px;">
|
|
505
|
+
<label style="display: block; margin-bottom: 8px; font-size: 14px; font-weight: 500;">
|
|
506
|
+
Citation Content
|
|
507
|
+
</label>
|
|
508
|
+
<textarea
|
|
509
|
+
class="footnote-content-input"
|
|
510
|
+
placeholder="Enter citation or reference (e.g., Smith, J. et al. (2023). Study Title. Journal Name.)"
|
|
511
|
+
style="
|
|
512
|
+
width: 100%;
|
|
513
|
+
min-height: 100px;
|
|
514
|
+
padding: 8px 12px;
|
|
515
|
+
border: 1px solid #d1d5db;
|
|
516
|
+
border-radius: 4px;
|
|
517
|
+
font-size: 14px;
|
|
518
|
+
font-family: inherit;
|
|
519
|
+
resize: vertical;
|
|
520
|
+
box-sizing: border-box;
|
|
521
|
+
"
|
|
522
|
+
></textarea>
|
|
523
|
+
<p style="margin: 8px 0 0 0; font-size: 12px; color: #6b7280;">
|
|
524
|
+
Tip: You can paste URLs directly - they'll be automatically converted to clickable links.
|
|
525
|
+
</p>
|
|
526
|
+
</div>
|
|
527
|
+
<div style="display: flex; gap: 8px; justify-content: flex-end;">
|
|
528
|
+
<button class="footnote-cancel-btn" style="
|
|
529
|
+
padding: 8px 16px;
|
|
530
|
+
border: 1px solid #d1d5db;
|
|
531
|
+
border-radius: 4px;
|
|
532
|
+
background: white;
|
|
533
|
+
color: #374151;
|
|
534
|
+
font-size: 14px;
|
|
535
|
+
font-weight: 500;
|
|
536
|
+
cursor: pointer;
|
|
537
|
+
">Cancel</button>
|
|
538
|
+
<button class="footnote-save-btn" style="
|
|
539
|
+
padding: 8px 16px;
|
|
540
|
+
border: none;
|
|
541
|
+
border-radius: 4px;
|
|
542
|
+
background: #3b82f6;
|
|
543
|
+
color: white;
|
|
544
|
+
font-size: 14px;
|
|
545
|
+
font-weight: 500;
|
|
546
|
+
cursor: pointer;
|
|
547
|
+
">Add Footnote</button>
|
|
548
|
+
</div>
|
|
549
|
+
`;
|
|
550
|
+
|
|
551
|
+
overlay.appendChild(modal);
|
|
552
|
+
document.body.appendChild(overlay);
|
|
553
|
+
|
|
554
|
+
const textarea = modal.querySelector('.footnote-content-input');
|
|
555
|
+
const saveBtn = modal.querySelector('.footnote-save-btn');
|
|
556
|
+
const cancelBtn = modal.querySelector('.footnote-cancel-btn');
|
|
557
|
+
|
|
558
|
+
setTimeout(() => textarea.focus(), 0);
|
|
559
|
+
|
|
560
|
+
const handleSave = () => {
|
|
561
|
+
const content = textarea.value;
|
|
562
|
+
onSave(content);
|
|
563
|
+
overlay.remove();
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
const handleCancel = () => {
|
|
567
|
+
overlay.remove();
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
saveBtn.addEventListener('click', handleSave);
|
|
571
|
+
cancelBtn.addEventListener('click', handleCancel);
|
|
572
|
+
overlay.addEventListener('click', (e) => {
|
|
573
|
+
if (e.target === overlay) {
|
|
574
|
+
handleCancel();
|
|
575
|
+
}
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
textarea.addEventListener('keydown', (e) => {
|
|
579
|
+
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
|
580
|
+
e.preventDefault();
|
|
581
|
+
handleSave();
|
|
582
|
+
} else if (e.key === 'Escape') {
|
|
583
|
+
e.preventDefault();
|
|
584
|
+
handleCancel();
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
clear() {
|
|
590
|
+
this.state = false;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Custom Paragraph with Footnotes
|
|
596
|
+
*/
|
|
597
|
+
class ParagraphWithFootnotes {
|
|
598
|
+
static get toolbox() {
|
|
599
|
+
return {
|
|
600
|
+
title: 'Paragraph',
|
|
601
|
+
icon: '<svg width="17" height="15" viewBox="0 0 336 276" xmlns="http://www.w3.org/2000/svg"><path d="M291 150V79c0-19-15-34-34-34H79c-19 0-34 15-34 34v42l67-44 81 72 56-29 42 30zm0 52l-43-30-56 30-81-67-66 39v23c0 19 15 34 34 34h178c17 0 31-13 34-29zM79 0h178c44 0 79 35 79 79v118c0 44-35 79-79 79H79c-44 0-79-35-79-79V79C0 35 35 0 79 0z"/></svg>'
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
static get contentless() {
|
|
606
|
+
return false;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
static get enableLineBreaks() {
|
|
610
|
+
return true;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
static get DEFAULT_PLACEHOLDER() {
|
|
614
|
+
return 'Start writing or press Tab to add content...';
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
constructor({ data, config, api, readOnly }) {
|
|
618
|
+
this.api = api;
|
|
619
|
+
this.readOnly = readOnly;
|
|
620
|
+
|
|
621
|
+
this._CSS = {
|
|
622
|
+
block: this.api.styles.block,
|
|
623
|
+
wrapper: 'ce-paragraph'
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
if (!this.readOnly) {
|
|
627
|
+
this.onKeyUp = this.onKeyUp.bind(this);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
this._placeholder = config.placeholder || ParagraphWithFootnotes.DEFAULT_PLACEHOLDER;
|
|
631
|
+
this._data = {};
|
|
632
|
+
this._element = null;
|
|
633
|
+
this._preserveBlank = config.preserveBlank !== undefined ? config.preserveBlank : false;
|
|
634
|
+
|
|
635
|
+
this.data = data;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
render() {
|
|
639
|
+
const div = document.createElement('DIV');
|
|
640
|
+
div.classList.add(this._CSS.wrapper, this._CSS.block);
|
|
641
|
+
div.contentEditable = !this.readOnly;
|
|
642
|
+
div.dataset.placeholder = this.api.i18n.t(this._placeholder);
|
|
643
|
+
|
|
644
|
+
if (this._data.text) {
|
|
645
|
+
div.innerHTML = this._data.text;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
if (!this.readOnly) {
|
|
649
|
+
div.addEventListener('keyup', this.onKeyUp);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
this._element = div;
|
|
653
|
+
|
|
654
|
+
return div;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
onKeyUp(event) {
|
|
658
|
+
if (event.code !== 'Backspace' && event.code !== 'Delete') {
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const { textContent } = this._element;
|
|
663
|
+
|
|
664
|
+
if (textContent === '') {
|
|
665
|
+
this._element.innerHTML = '';
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
validate(savedData) {
|
|
670
|
+
if (savedData.text?.trim() === '' && !this._preserveBlank) {
|
|
671
|
+
return false;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
return true;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
save(toolsContent) {
|
|
678
|
+
// Clone the element to work with it without modifying the DOM
|
|
679
|
+
const clone = toolsContent.cloneNode(true);
|
|
680
|
+
|
|
681
|
+
// Extract footnotes before removing markers
|
|
682
|
+
const footnotes = this.extractFootnotes(clone);
|
|
683
|
+
|
|
684
|
+
// Remove all footnote markers from the clone to get clean text
|
|
685
|
+
const markers = clone.querySelectorAll('.footnote-marker');
|
|
686
|
+
markers.forEach(marker => marker.remove());
|
|
687
|
+
|
|
688
|
+
const data = {
|
|
689
|
+
text: clone.innerHTML
|
|
690
|
+
};
|
|
691
|
+
|
|
692
|
+
// Add footnotes array if any exist
|
|
693
|
+
if (footnotes.length > 0) {
|
|
694
|
+
data.footnotes = footnotes;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
return data;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
extractFootnotes(element) {
|
|
701
|
+
const footnotes = [];
|
|
702
|
+
const markers = element.querySelectorAll('.footnote-marker');
|
|
703
|
+
|
|
704
|
+
markers.forEach((marker) => {
|
|
705
|
+
const footnoteId = marker.dataset.footnoteId;
|
|
706
|
+
const footnoteContent = marker.dataset.footnoteContent;
|
|
707
|
+
|
|
708
|
+
if (footnoteId && footnoteContent) {
|
|
709
|
+
const range = document.createRange();
|
|
710
|
+
range.selectNodeContents(element);
|
|
711
|
+
range.setEnd(marker, 0);
|
|
712
|
+
const position = range.toString().length;
|
|
713
|
+
|
|
714
|
+
footnotes.push({
|
|
715
|
+
id: footnoteId,
|
|
716
|
+
content: footnoteContent,
|
|
717
|
+
position: position
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
return footnotes;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
merge(data) {
|
|
726
|
+
const newData = {
|
|
727
|
+
text: this.data.text + data.text
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
this.data = newData;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
get data() {
|
|
734
|
+
let text = this._element ? this._element.innerHTML : this._data.text || '';
|
|
735
|
+
|
|
736
|
+
this._data.text = text;
|
|
737
|
+
|
|
738
|
+
if (this._element) {
|
|
739
|
+
const footnotes = this.extractFootnotes(this._element);
|
|
740
|
+
if (footnotes.length > 0) {
|
|
741
|
+
this._data.footnotes = footnotes;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
return this._data;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
set data(data) {
|
|
749
|
+
this._data = data || {};
|
|
750
|
+
|
|
751
|
+
if (this._element) {
|
|
752
|
+
this._element.innerHTML = this._data.text || '';
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
onPaste(event) {
|
|
757
|
+
const data = {
|
|
758
|
+
text: event.detail.data.innerHTML
|
|
759
|
+
};
|
|
760
|
+
|
|
761
|
+
this.data = data;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
static get conversionConfig() {
|
|
765
|
+
return {
|
|
766
|
+
export: 'text',
|
|
767
|
+
import: 'text'
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
static get sanitize() {
|
|
772
|
+
return {
|
|
773
|
+
text: {
|
|
774
|
+
br: true,
|
|
775
|
+
sup: {
|
|
776
|
+
class: 'footnote-marker',
|
|
777
|
+
'data-footnote-id': true,
|
|
778
|
+
'data-footnote-content': true
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
static get isReadOnlySupported() {
|
|
785
|
+
return true;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
get currentBlock() {
|
|
789
|
+
return this.api.blocks.getCurrentBlockIndex();
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// Initialize EditorJS
|
|
794
|
+
try {
|
|
795
|
+
window.editor = new EditorJS({
|
|
796
|
+
holder: 'editorjs',
|
|
797
|
+
placeholder: 'Start writing and add footnotes...',
|
|
798
|
+
tools: {
|
|
799
|
+
paragraph: {
|
|
800
|
+
class: ParagraphWithFootnotes,
|
|
801
|
+
inlineToolbar: true,
|
|
802
|
+
config: {
|
|
803
|
+
placeholder: 'Click to start writing...'
|
|
804
|
+
}
|
|
805
|
+
},
|
|
806
|
+
header: {
|
|
807
|
+
class: Header,
|
|
808
|
+
inlineToolbar: true
|
|
809
|
+
},
|
|
810
|
+
footnote: {
|
|
811
|
+
class: FootnoteTool
|
|
812
|
+
}
|
|
813
|
+
},
|
|
814
|
+
data: {
|
|
815
|
+
blocks: [
|
|
816
|
+
{
|
|
817
|
+
type: 'paragraph',
|
|
818
|
+
data: {
|
|
819
|
+
text: 'Welcome to the Panda Editor footnote tool test! Select this text and click the footnote button to add a citation.'
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
]
|
|
823
|
+
},
|
|
824
|
+
onReady: () => {
|
|
825
|
+
showStatus('✅ Editor loaded successfully! You can now add footnotes by selecting text and clicking the footnote button.', 'success');
|
|
826
|
+
}
|
|
827
|
+
});
|
|
828
|
+
} catch (error) {
|
|
829
|
+
showStatus('❌ Error loading editor: ' + error.message, 'error');
|
|
830
|
+
console.error('Editor initialization error:', error);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// Save function
|
|
834
|
+
window.saveData = async function() {
|
|
835
|
+
try {
|
|
836
|
+
const outputData = await window.editor.save();
|
|
837
|
+
document.getElementById('output').textContent = JSON.stringify(outputData, null, 2);
|
|
838
|
+
|
|
839
|
+
renderPreview(outputData);
|
|
840
|
+
showStatus('✅ Data saved successfully!', 'success');
|
|
841
|
+
} catch (error) {
|
|
842
|
+
console.error('Saving failed: ', error);
|
|
843
|
+
document.getElementById('output').textContent = 'Error: ' + error.message;
|
|
844
|
+
showStatus('❌ Error saving: ' + error.message, 'error');
|
|
845
|
+
}
|
|
846
|
+
};
|
|
847
|
+
|
|
848
|
+
// Clear function
|
|
849
|
+
window.clearEditor = async function() {
|
|
850
|
+
await window.editor.clear();
|
|
851
|
+
document.getElementById('output').textContent = 'Editor cleared.';
|
|
852
|
+
document.getElementById('rendered').innerHTML = 'The rendered HTML will appear here after you save...';
|
|
853
|
+
showStatus('Editor cleared.', 'info');
|
|
854
|
+
};
|
|
855
|
+
|
|
856
|
+
// Load sample function
|
|
857
|
+
window.loadSample = async function() {
|
|
858
|
+
await window.editor.render({
|
|
859
|
+
blocks: [
|
|
860
|
+
{
|
|
861
|
+
type: 'paragraph',
|
|
862
|
+
data: {
|
|
863
|
+
text: 'Climate change has accelerated significantly since 1980 with global temperatures rising 1.1°C above pre-industrial levels.',
|
|
864
|
+
footnotes: [
|
|
865
|
+
{
|
|
866
|
+
id: 'fn-sample-1',
|
|
867
|
+
content: 'IPCC. (2023). Climate Change 2023: Synthesis Report. https://www.ipcc.ch/report/ar6/syr/',
|
|
868
|
+
position: 57
|
|
869
|
+
},
|
|
870
|
+
{
|
|
871
|
+
id: 'fn-sample-2',
|
|
872
|
+
content: 'NASA. (2023). Global Climate Change: Vital Signs of the Planet.',
|
|
873
|
+
position: 116
|
|
874
|
+
}
|
|
875
|
+
]
|
|
876
|
+
}
|
|
877
|
+
},
|
|
878
|
+
{
|
|
879
|
+
type: 'paragraph',
|
|
880
|
+
data: {
|
|
881
|
+
text: 'Renewable energy adoption has increased dramatically in recent years.'
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
]
|
|
885
|
+
});
|
|
886
|
+
showStatus('Sample data loaded with two footnotes.', 'success');
|
|
887
|
+
};
|
|
888
|
+
|
|
889
|
+
// Render preview (simulated Ruby rendering)
|
|
890
|
+
window.renderPreview = function(data) {
|
|
891
|
+
let html = '';
|
|
892
|
+
const allFootnotes = [];
|
|
893
|
+
let footnoteCounter = 0;
|
|
894
|
+
|
|
895
|
+
// First pass: collect all footnotes with their block index and assign numbers
|
|
896
|
+
data.blocks.forEach((block, blockIndex) => {
|
|
897
|
+
if (block.type === 'paragraph' && block.data.footnotes && block.data.footnotes.length > 0) {
|
|
898
|
+
// Sort by position ascending (reading order) to assign numbers correctly
|
|
899
|
+
const sortedForNumbering = [...block.data.footnotes].sort((a, b) => a.position - b.position);
|
|
900
|
+
|
|
901
|
+
sortedForNumbering.forEach(fn => {
|
|
902
|
+
footnoteCounter++;
|
|
903
|
+
allFootnotes.push({
|
|
904
|
+
blockIndex: blockIndex,
|
|
905
|
+
footnote: fn,
|
|
906
|
+
number: footnoteCounter
|
|
907
|
+
});
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
// Second pass: render blocks with correct footnote numbers
|
|
913
|
+
data.blocks.forEach((block, blockIndex) => {
|
|
914
|
+
if (block.type === 'paragraph') {
|
|
915
|
+
let text = block.data.text;
|
|
916
|
+
|
|
917
|
+
if (block.data.footnotes && block.data.footnotes.length > 0) {
|
|
918
|
+
// Get footnotes for this block with their assigned numbers
|
|
919
|
+
const blockFootnotes = allFootnotes
|
|
920
|
+
.filter(item => item.blockIndex === blockIndex)
|
|
921
|
+
.sort((a, b) => b.footnote.position - a.footnote.position); // Sort descending for insertion
|
|
922
|
+
|
|
923
|
+
blockFootnotes.forEach(item => {
|
|
924
|
+
const marker = `<sup id="fnref:${item.number}"><a href="#fn:${item.number}" class="footnote" style="color: #3b82f6; text-decoration: none;">${item.number}</a></sup>`;
|
|
925
|
+
text = text.slice(0, item.footnote.position) + marker + text.slice(item.footnote.position);
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
html += `<p>${text}</p>`;
|
|
930
|
+
} else if (block.type === 'header') {
|
|
931
|
+
html += `<h${block.data.level}>${block.data.text}</h${block.data.level}>`;
|
|
932
|
+
}
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
// Render footnotes section in correct order
|
|
936
|
+
if (allFootnotes.length > 0) {
|
|
937
|
+
html += '<div style="margin-top: 30px; padding: 20px; background: #f9fafb; border-radius: 8px;">';
|
|
938
|
+
html += '<h3 style="margin: 0 0 15px 0; font-size: 16px;">Sources/References</h3>';
|
|
939
|
+
html += '<ol style="margin: 0; padding-left: 20px; line-height: 1.8;">';
|
|
940
|
+
|
|
941
|
+
// Sort by number to ensure correct order
|
|
942
|
+
allFootnotes.sort((a, b) => a.number - b.number).forEach(item => {
|
|
943
|
+
html += `<li id="fn:${item.number}" style="margin-bottom: 10px;">`;
|
|
944
|
+
html += `<p style="margin: 0;">${item.footnote.content} <a href="#fnref:${item.number}" style="color: #3b82f6; text-decoration: none;">↩</a></p>`;
|
|
945
|
+
html += '</li>';
|
|
946
|
+
});
|
|
947
|
+
html += '</ol></div>';
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
document.getElementById('rendered').innerHTML = html || 'No content yet.';
|
|
951
|
+
};
|
|
952
|
+
|
|
953
|
+
console.log('✅ Panda Editor footnote test loaded successfully!');
|
|
954
|
+
console.log('Editor instance available as window.editor');
|
|
955
|
+
</script>
|
|
956
|
+
</body>
|
|
957
|
+
</html>
|