Reputation: 23
Using CKEditor5, WTForms, Flask.
If you think I should remove and repost as a comment on my original question, let me know.
Backstory
This is somewhat of a follow-up to my previous question here. CKEditor 4 (which uses the flask-ckeditor library) has since gone out of support, so I've updated my project to use CKEditor 5. The same issue is occurring again where my JavaScript code to dynamically add an "Entry
" field doesn't render the CKE5 field on creation.
I am very grateful to the previous answerer, but very unfamiliar with JavaScript, especially a case as niche as this. My suspicion is that the ClassicEditor.create
and/or the document.querySelector
shown below only get run when the webpage is first loaded and therefore miss anything created after that time, even with the appropriate class attached. Even if I am correct in this suspicion, I don't know where to begin adapting a more effective one and would appreciate any guidance on the matter.
The Setup
I have a field (entries
) in my form that has a dynamic number of entries; they can be both added and removed. I previously utilized the answer given (at the link above) to create CKEditor Fields rather than plain TextArea fields. After successfully migrating the rest of my form field TextAreas to CKE5, the dynamically created ones are, again, appearing as plain TextArea instead of CKEditor. The CKE5 implementation used is adapted from the documentation on their website for "Quickstart Installation from CDN".
The code below works, nothing is "broken", I get no Console or Flask errors, but the RTE doesn't show up -- only a regular textarea.
My attempt to modify the previous solution for CKE5
I tried to add the class attribute "ckeditor5" to all dynamically added fields (in app.js) and the bullets
(in webpage.html). This doesn't seem to be enough. As I mentioned, I suspect the functions which replace textareas based on this class are not checking for dynamically added tags after the initial webpage is loaded.
webpage.html
<link rel="stylesheet" href="https://cdn.ckeditor.com/ckeditor5/43.0.0/ckeditor5.css" />
.
.
.
<form method="POST" enctype="multipart/form-data">
{{form.csrf_token}}
<div id="entries">
{% for f in form.entries%}
<div>
{{ f.bullet.label() }}
{{ f.bullet(class_="ckeditor5") }}
<button type="button">Remove</button>
</div>
{% endfor -%}
</div>
<!-- example of another form field whose CKE5 field is working w/ this implementation -->
<p>{{ form.plans.label }} <br> {{ form.plans(class_="ckeditor5") }}</p>
<p><input type="submit" value="Submit"></p>
</form>
.
.
.
<!-- CKE5 JavaScript from documentation on their website -->
<script type="importmap">
{
"imports": {
"ckeditor5": "https://cdn.ckeditor.com/ckeditor5/43.0.0/ckeditor5.js",
"ckeditor5/": "https://cdn.ckeditor.com/ckeditor5/43.0.0/"
}
}
</script>
<script type="module">
import {
ClassicEditor,
AccessibilityHelp,
Autosave,
Bold,
Code,
Essentials,
Heading,
Indent,
IndentBlock,
Italic,
List,
ListProperties,
Markdown,
Paragraph,
RemoveFormat,
SelectAll,
SpecialCharacters,
SpecialCharactersArrows,
SpecialCharactersCurrency,
SpecialCharactersEssentials,
SpecialCharactersLatin,
SpecialCharactersMathematical,
SpecialCharactersText,
Strikethrough,
Subscript,
Superscript,
Underline,
Undo
} from 'ckeditor5';
const elements = document.querySelectorAll(".ckeditor5");
if (elements.length > 0) {
for (let i = 0; i < elements.length; i++) {
ClassicEditor
.create( elements[i] , {
plugins: [ AccessibilityHelp,
Autosave,
Bold,
Code,
Essentials,
Heading,
Indent,
IndentBlock,
Italic,
List,
ListProperties,
Markdown,
Paragraph,
RemoveFormat,
SelectAll,
SpecialCharacters,
SpecialCharactersArrows,
SpecialCharactersCurrency,
SpecialCharactersEssentials,
SpecialCharactersLatin,
SpecialCharactersMathematical,
SpecialCharactersText,
Strikethrough,
Subscript,
Superscript,
Underline,
Undo
],
toolbar: {
items: [
'undo',
'redo',
'|',
'selectAll',
'|',
'heading',
'|',
'bold',
'italic',
'underline',
'strikethrough',
'subscript',
'superscript',
'code',
'removeFormat',
'|',
'specialCharacters',
'|',
'bulletedList',
'numberedList',
'outdent',
'indent',
'|',
'accessibilityHelp'
], shouldNotGroupWhenFull: false
}
} )
.then( /* ... */ )
.catch( /* ... */ );
}
}
</script>
app.js
$(document).ready(function() {
const entriesEl = $('#entries');
$('#btn-add').click(() => {
// If the button to add is clicked...
let ids = ['entries-0-bullet'];
const sel = 'textarea[name$="-bullet"]';
const entries = entriesEl.find(sel);
if (entries.length) {
// ... and there are already input fields ...
const lastEntry = entries.last().closest('div');
ids = $.map($(lastEntry).children(sel), function(elem) {
// ... extract the name attribute of the last input field
// and generate a new unique id from it.
const attr = $(elem).attr('name'),
s = attr.replace(/(\w+)-(\d+)-bullet$/, (match, p1, p2) => {
return `${p1}-${parseInt(p2)+1}-bullet`;
});
return s;
});
}
// For each id created a block with the new input field.
// Register a function to remove the block and configure the CKEditor.
$.each(ids, function(index, value) {
const newEntry = $.parseHTML(`<div>
<label for="${value}">Entry</label>
<textarea class="ckeditor5" id="${value}" name="${value}"></textarea>
<button type="button">Remove</button>
</div>`);
$(newEntry).children('.btn-remove').click(function() {
$(this).closest('div').remove();
})
entriesEl.append(newEntry);
});
});
// Register a function to remove fields that already exist.
$('.btn-remove').click(function() {
$(this).closest('div').remove();
});
});
forms.py
class Entry(FlaskForm):
class Meta:
csrf=False
bullet = TextAreaField(validators=[validators.DataRequired(), validators.Length(max=1000)])
class MyForm(FlaskForm):
plans = TextAreaField('Plans')
entries= FieldList(FormField(Entry), min_entries=0)
Upvotes: 0
Views: 89
Reputation: 8552
Your code is missing a call to create an editor for the text area in the newly created "Entry". This means that the normal text area is displayed.
The following code shows you a solution based on the tried and tested approach. For the sake of clarity, I have refrained from using jQuery.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Index</title>
<link rel="stylesheet" href="https://cdn.ckeditor.com/ckeditor5/43.0.0/ckeditor5.css" />
</head>
<body>
<form method="post">
{{ form.csrf_token }}
<div>
<button type="button" id="btn-add">Add</button>
</div>
<div id="entries">
{% for f in form.entries %}
<div>
{{ f.bullet.label() }}
{{ f.bullet(class_='ckeditor5') }}
<button type="button" class="btn-remove">Remove</button>
</div>
{% endfor -%}
</div>
<div>
{{ form.plans.label() }}
{{ form.plans(class_="ckeditor5") }}
</div>
<div>
<button type="submit">Submit</button>
</div>
</form>
<script type="importmap">
{
"imports": {
"ckeditor5": "https://cdn.ckeditor.com/ckeditor5/43.0.0/ckeditor5.js",
"ckeditor5/": "https://cdn.ckeditor.com/ckeditor5/43.0.0/"
}
}
</script>
<script type="module">
import {
ClassicEditor,
Essentials,
Bold,
Italic,
Font,
Paragraph
} from 'ckeditor5';
const editors = {};
/**
* Create a new editor for the specified element.
*/
const createEditor = (elem) => {
ClassicEditor.create(elem, {
plugins: [ Essentials, Bold, Italic, Font, Paragraph ],
toolbar: {
items: [
'undo', 'redo', '|', 'bold', 'italic', '|',
'fontSize', 'fontFamily', 'fontColor', 'fontBackgroundColor'
]
}
})
.then(editor => { editors[elem.name] = editor; })
.catch(console.error);
};
/**
* Remove the surrounding div element and destroy the associated editor.
*/
const removeEntryEl = (event) => {
if (entriesEl.childElementCount > 0) {
const elem = event.target.closest('div');
const nameAttr = elem.querySelector('textarea[name^="entries-"]').name;
if (nameAttr in editors) {
editors[nameAttr]
.destroy()
.then(() => { delete editors[nameAttr]; })
.catch(console.error);
}
elem.remove();
}
};
// Create editors for all existing text areas with a class "ckeditor5".
document
.querySelectorAll('.ckeditor5')
.forEach(createEditor);
const entriesEl = document.querySelector('#entries');
document.getElementById('btn-add').addEventListener('click', () => {
// If the button to add is clicked...
let ids = ['entries-0-bullet']
if (entriesEl.childElementCount > 0) {
// ... and there are already input fields ...
const sel = 'textarea[name^="entries-"]';
ids = Array.from(
entriesEl.lastElementChild.querySelectorAll(sel),
field => {
// ... extract the name attribute of the last input field
// and generate a new unique id from it.
return field.name.replace(
/^entries-(\d+)-(\w+)$/,
(match, p1, p2) => `entries-${parseInt(p1)+1}-${p2}`
);
});
}
// For each id created a block with the new input field.
// Register a function to remove the block and configure the CKEditor.
ids.forEach(id => {
const elem = document.createElement('div');
elem.innerHTML = `
<label for="${id}">Bullet</label>
<textarea class="ckeditor5" id="${id}" name="${id}"></textarea>
<button type="button" class="btn-remove">Remove</button>
`;
entriesEl.append(elem);
createEditor(elem.querySelector('.ckeditor5'));
elem
.querySelector('.btn-remove')
.addEventListener('click', removeEntryEl);
})
});
// Register a function to remove fields that already exist.
document.querySelectorAll('.btn-remove').forEach(btn => {
btn.addEventListener('click', removeEntryEl);
});
</script>
</body>
</html>
Upvotes: 0