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,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ParagraphWithFootnotes - Custom Paragraph Block for EditorJS with Footnote Support
|
|
3
|
+
*
|
|
4
|
+
* This extends the default Paragraph tool to add support for storing and
|
|
5
|
+
* managing footnote data alongside the text content.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export default class ParagraphWithFootnotes {
|
|
9
|
+
/**
|
|
10
|
+
* EditorJS block tool interface
|
|
11
|
+
*/
|
|
12
|
+
static get toolbox() {
|
|
13
|
+
return {
|
|
14
|
+
title: 'Paragraph',
|
|
15
|
+
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>'
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
static get contentless() {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
static get enableLineBreaks() {
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
static get DEFAULT_PLACEHOLDER() {
|
|
28
|
+
return 'Start writing or press Tab to add content...';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Allow to press Enter inside the CodeTool textarea
|
|
33
|
+
* @returns {boolean}
|
|
34
|
+
* @public
|
|
35
|
+
*/
|
|
36
|
+
static get enableLineBreaks() {
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Constructor
|
|
42
|
+
* @param {object} params - Tool parameters
|
|
43
|
+
* @param {object} params.data - Previously saved data
|
|
44
|
+
* @param {object} params.config - Tool config
|
|
45
|
+
* @param {object} params.api - EditorJS API
|
|
46
|
+
* @param {boolean} params.readOnly - Read-only mode
|
|
47
|
+
*/
|
|
48
|
+
constructor({ data, config, api, readOnly }) {
|
|
49
|
+
this.api = api;
|
|
50
|
+
this.readOnly = readOnly;
|
|
51
|
+
|
|
52
|
+
this._CSS = {
|
|
53
|
+
block: this.api.styles.block,
|
|
54
|
+
wrapper: 'ce-paragraph'
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
if (!this.readOnly) {
|
|
58
|
+
this.onKeyUp = this.onKeyUp.bind(this);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
this._placeholder = config.placeholder ? config.placeholder : ParagraphWithFootnotes.DEFAULT_PLACEHOLDER;
|
|
62
|
+
this._data = {};
|
|
63
|
+
this._element = null;
|
|
64
|
+
this._preserveBlank = config.preserveBlank !== undefined ? config.preserveBlank : false;
|
|
65
|
+
|
|
66
|
+
this.data = data;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Create paragraph element
|
|
71
|
+
* @returns {HTMLElement}
|
|
72
|
+
*/
|
|
73
|
+
render() {
|
|
74
|
+
const div = document.createElement('DIV');
|
|
75
|
+
div.classList.add(this._CSS.wrapper, this._CSS.block);
|
|
76
|
+
div.contentEditable = !this.readOnly;
|
|
77
|
+
div.dataset.placeholder = this.api.i18n.t(this._placeholder);
|
|
78
|
+
|
|
79
|
+
if (this._data.text) {
|
|
80
|
+
div.innerHTML = this._data.text;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!this.readOnly) {
|
|
84
|
+
div.addEventListener('keyup', this.onKeyUp);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
this._element = div;
|
|
88
|
+
|
|
89
|
+
return div;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Handle keyup event
|
|
94
|
+
* @param {KeyboardEvent} event
|
|
95
|
+
*/
|
|
96
|
+
onKeyUp(event) {
|
|
97
|
+
if (event.code !== 'Backspace' && event.code !== 'Delete') {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const { textContent } = this._element;
|
|
102
|
+
|
|
103
|
+
if (textContent === '') {
|
|
104
|
+
this._element.innerHTML = '';
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Validate saved data
|
|
110
|
+
* @param {object} savedData - Data to validate
|
|
111
|
+
* @returns {boolean}
|
|
112
|
+
*/
|
|
113
|
+
validate(savedData) {
|
|
114
|
+
if (savedData.text?.trim() === '' && !this._preserveBlank) {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Save paragraph data
|
|
123
|
+
* @param {HTMLElement} toolsContent - Paragraph element
|
|
124
|
+
* @returns {object} Saved data
|
|
125
|
+
*/
|
|
126
|
+
save(toolsContent) {
|
|
127
|
+
// Clone the element to work with it without modifying the DOM
|
|
128
|
+
const clone = toolsContent.cloneNode(true);
|
|
129
|
+
|
|
130
|
+
// Extract footnotes before removing markers
|
|
131
|
+
const footnotes = this.extractFootnotes(clone);
|
|
132
|
+
|
|
133
|
+
// Remove all footnote markers from the clone to get clean text
|
|
134
|
+
const markers = clone.querySelectorAll('.footnote-marker');
|
|
135
|
+
markers.forEach(marker => marker.remove());
|
|
136
|
+
|
|
137
|
+
const data = {
|
|
138
|
+
text: clone.innerHTML
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// Add footnotes array if any exist
|
|
142
|
+
if (footnotes.length > 0) {
|
|
143
|
+
data.footnotes = footnotes;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return data;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Extract footnotes from the paragraph content
|
|
151
|
+
* @param {HTMLElement} element - Paragraph element
|
|
152
|
+
* @returns {Array} Array of footnote objects
|
|
153
|
+
*/
|
|
154
|
+
extractFootnotes(element) {
|
|
155
|
+
const footnotes = [];
|
|
156
|
+
const markers = element.querySelectorAll('.footnote-marker');
|
|
157
|
+
|
|
158
|
+
markers.forEach((marker) => {
|
|
159
|
+
const footnoteId = marker.dataset.footnoteId;
|
|
160
|
+
const footnoteContent = marker.dataset.footnoteContent;
|
|
161
|
+
|
|
162
|
+
if (footnoteId && footnoteContent) {
|
|
163
|
+
// Calculate position by getting all text before this marker
|
|
164
|
+
const range = document.createRange();
|
|
165
|
+
range.selectNodeContents(element);
|
|
166
|
+
range.setEnd(marker, 0);
|
|
167
|
+
const position = range.toString().length;
|
|
168
|
+
|
|
169
|
+
footnotes.push({
|
|
170
|
+
id: footnoteId,
|
|
171
|
+
content: footnoteContent,
|
|
172
|
+
position: position
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
return footnotes;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Merge tool with similar block
|
|
182
|
+
* @param {object} data - Saved data from other block
|
|
183
|
+
*/
|
|
184
|
+
merge(data) {
|
|
185
|
+
const newData = {
|
|
186
|
+
text: this.data.text + data.text
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
this.data = newData;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Get current data
|
|
194
|
+
* @returns {object}
|
|
195
|
+
*/
|
|
196
|
+
get data() {
|
|
197
|
+
let text = this._element ? this._element.innerHTML : this._data.text || '';
|
|
198
|
+
|
|
199
|
+
this._data.text = text;
|
|
200
|
+
|
|
201
|
+
// Also update footnotes from current markers
|
|
202
|
+
if (this._element) {
|
|
203
|
+
const footnotes = this.extractFootnotes(this._element);
|
|
204
|
+
if (footnotes.length > 0) {
|
|
205
|
+
this._data.footnotes = footnotes;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return this._data;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Set data
|
|
214
|
+
* @param {object} data - Data to set
|
|
215
|
+
*/
|
|
216
|
+
set data(data) {
|
|
217
|
+
this._data = data || {};
|
|
218
|
+
|
|
219
|
+
if (this._element) {
|
|
220
|
+
this._element.innerHTML = this._data.text || '';
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Used by EditorJS paste handling
|
|
226
|
+
* @param {string} content - Content to set
|
|
227
|
+
*/
|
|
228
|
+
onPaste(event) {
|
|
229
|
+
const data = {
|
|
230
|
+
text: event.detail.data.innerHTML
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
this.data = data;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Enable Conversion Toolbar
|
|
238
|
+
* @returns {object}
|
|
239
|
+
*/
|
|
240
|
+
static get conversionConfig() {
|
|
241
|
+
return {
|
|
242
|
+
export: 'text', // use 'text' property for other blocks
|
|
243
|
+
import: 'text' // fill 'text' property from other block's export string
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Sanitizer rules
|
|
249
|
+
* @returns {object}
|
|
250
|
+
*/
|
|
251
|
+
static get sanitize() {
|
|
252
|
+
return {
|
|
253
|
+
text: {
|
|
254
|
+
br: true,
|
|
255
|
+
sup: {
|
|
256
|
+
class: 'footnote-marker',
|
|
257
|
+
'data-footnote-id': true,
|
|
258
|
+
'data-footnote-content': true
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Returns true to notify core that read-only is supported
|
|
266
|
+
* @returns {boolean}
|
|
267
|
+
*/
|
|
268
|
+
static get isReadOnlySupported() {
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Get current Tool`s data
|
|
274
|
+
* @returns {object} Current data
|
|
275
|
+
* @public
|
|
276
|
+
*/
|
|
277
|
+
get currentBlock() {
|
|
278
|
+
return this.api.blocks.getCurrentBlockIndex();
|
|
279
|
+
}
|
|
280
|
+
}
|