diff --git a/djangocms_text/apps.py b/djangocms_text/apps.py index e84229a5..686edae0 100644 --- a/djangocms_text/apps.py +++ b/djangocms_text/apps.py @@ -8,7 +8,40 @@ class TextConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" def ready(self): - register(check_ckeditor_settings) + register(check_ckeditor_settings) + + from django.contrib.admin import site + + registered_inline_fields = ["HTMLField", "CharField"] + inline_models = {} + blacklist_apps = ["auth", "admin", "sessions", "contenttypes", "sites", "cms", "djangocms_text", "djangocms_alias"] + for model, modeladmin in site._registry.items(): + if model._meta.app_label in blacklist_apps: + continue + try: + form = modeladmin.get_form(request=None) # Worth a try + except Exception: + form = getattr(modeladmin, "form", None) + if form: + for field_name, field_instance in form.base_fields.items(): + if field_instance.__class__.__name__ in registered_inline_fields: + inline_models[ + f"{model._meta.app_label}-{model._meta.model_name}-{field_name}" + ] = field_instance.__class__.__name__ + + from cms.plugin_pool import plugin_pool + for plugin in plugin_pool.plugins.values(): + model = plugin.model + if model._meta.app_label in blacklist_apps: + continue + form = plugin.form + for field_name, field_instance in form.base_fields.items(): + if field_instance.__class__.__name__ in registered_inline_fields: + inline_models[ + f"{model._meta.app_label}-{model._meta.model_name}-{field_name}" + ] = field_instance.__class__.__name__ + + self.inline_models = inline_models def check_ckeditor_settings(app_configs, **kwargs): diff --git a/djangocms_text/cms_toolbars.py b/djangocms_text/cms_toolbars.py index 1a2c927c..e2e0cce3 100644 --- a/djangocms_text/cms_toolbars.py +++ b/djangocms_text/cms_toolbars.py @@ -1,5 +1,6 @@ from urllib.parse import urlparse, urlunparse +from django.apps import apps from django.forms import forms from django.http import QueryDict from django.templatetags.static import static @@ -7,7 +8,7 @@ from django.utils.translation import gettext_lazy as _ from cms.cms_toolbars import CMSToolbar -from cms.toolbar.items import Button, ButtonList +from cms.toolbar.items import Button, ButtonList, TemplateItem from cms.toolbar_pool import toolbar_pool from . import settings @@ -51,20 +52,33 @@ def inline_editing(self): return inline_editing def populate(self): - if self.toolbar.edit_mode_active: + if self.toolbar.edit_mode_active or self.toolbar.structure_mode_active: item = ButtonList(side=self.toolbar.RIGHT) item.add_item( IconButton( name=_("Toggle inline editing mode for text plugins"), url=self.get_full_path_with_param( "inline_editing", int(not self.inline_editing) - ), + ).replace("/structure/","/edit/"), active=self.inline_editing, extra_classes=["cms-icon cms-icon-pencil"], ), ) self.toolbar.add_item(item) + config = settings.CKEDITOR_SETTINGS # TODO: Change to TEXT_EDITOR_SETTINGS + if "toolbar_HTMLField" in config: + config["toolbar"] = config["toolbar_HTMLField"] + item = TemplateItem( + "cms/toolbar/config.html", + extra_context={ + "html_field_config": config, + "allowed_inlines": apps.get_app_config("djangocms_text").inline_models, + }, + side=self.toolbar.RIGHT, + ) + self.toolbar.add_item(item) + def get_full_path_with_param(self, key, value): """ Adds key=value to the query parameters, replacing an existing key if necessary diff --git a/djangocms_text/contrib/text_tinymce/__init__.py b/djangocms_text/contrib/text_tinymce/__init__.py index e69de29b..7588f4c4 100644 --- a/djangocms_text/contrib/text_tinymce/__init__.py +++ b/djangocms_text/contrib/text_tinymce/__init__.py @@ -0,0 +1,12 @@ +from djangocms_text.editors import RTEConfig + + +tinymce = RTEConfig( + name="tinymce", + config="TINYMCE", + js=( + "https://cdn.tiny.cloud/1/no-api-key/tinymce/7/tinymce.min.js", + "djangocms_text/bundles/bundle.tinymce.min.js", + ), + css={"all": ("djangocms_text/css/cms.tinymce.css",)}, +) diff --git a/djangocms_text/templates/cms/toolbar/config.html b/djangocms_text/templates/cms/toolbar/config.html new file mode 100644 index 00000000..ec6d92be --- /dev/null +++ b/djangocms_text/templates/cms/toolbar/config.html @@ -0,0 +1,2 @@ +{% if html_field_config %}{{ html_field_config|json_script:"cms-cfg-htmlfield-inline-config" }}{% endif %} +{% if allowed_inlines %}{{ allowed_inlines|json_script:"cms-generic-inline-fields" }}{% endif %} \ No newline at end of file diff --git a/private/js/cms.ckeditor5.js b/private/js/cms.ckeditor5.js index c21fd302..93e424dd 100644 --- a/private/js/cms.ckeditor5.js +++ b/private/js/cms.ckeditor5.js @@ -220,7 +220,10 @@ class CMSCKEditor5Plugin { // returns the edited html code getHTML (el) { - return this._editors[el.id].getData(); + if (el.id in this._editors) { + return this._editors[el.id].getData(); + } + return undefined; } // returns the edited content as json @@ -231,8 +234,10 @@ class CMSCKEditor5Plugin { // destroy the editor destroyEditor (el) { - this._editors[el.id].destroy(); - delete this._editors[el.id]; + if (el.id in this._editors) { + this._editors[el.id].destroy(); + delete this._editors[el.id]; + } } _init() { diff --git a/private/js/cms.dialog.js b/private/js/cms.dialog.js index bea2521c..fe297649 100644 --- a/private/js/cms.dialog.js +++ b/private/js/cms.dialog.js @@ -4,7 +4,7 @@ class CmsDialog { /** - * Constructor for creating an instance of the class whowing a django CMS modal in a + * Constructor for creating an instance of the class showing a django CMS modal in a * modal HTML dialog element to show a plugin admin form in an iframe. * * The django CMS modal is resizable (thanks to CSS) and movable. It cannot be diff --git a/private/js/cms.editor.js b/private/js/cms.editor.js index 28c27664..0269b317 100644 --- a/private/js/cms.editor.js +++ b/private/js/cms.editor.js @@ -2,6 +2,8 @@ /* jshint esversion: 6 */ /* global window, document, fetch, IntersectionObserver, URLSearchParams, console */ +import CmsTextEditor from './cms.texteditor.js'; + // ############################################################################# // CMS Editor // ############################################################################# @@ -12,6 +14,7 @@ class CMSEditor { // Initialize the editor object constructor() { this._editors = []; + this._generic_editors = []; this._options = {}; this._editor_settings = {}; @@ -97,12 +100,21 @@ class CMSEditor { ); // Create editor - window.cms_editor_plugin.create( - el, - inModal, - content, settings, - el.tagName !== 'TEXTAREA' ? () => this.saveData(el) : () => {} - ); + if (el.dataset.cmsType === 'TextPlugin' || el.dataset.cmsType === 'HTMLField') { + window.cms_editor_plugin.create( + el, + inModal, + content, settings, + el.tagName !== 'TEXTAREA' ? () => this.saveData(el) : () => { + } + ); + } else if (el.dataset.cmsType === 'CharField') { + this._generic_editors.push(new CmsTextEditor(el, { + spellcheck: el.dataset.spellcheck || 'false', + }, + (el) => this.saveData(el) + )); + } this._editors.push(el); } @@ -128,38 +140,49 @@ class CMSEditor { threshold: 0.05 }); + let generic_inline_fields = document.getElementById('cms-generic-inline-fields') || {}; + if (generic_inline_fields) { + generic_inline_fields = JSON.parse(generic_inline_fields.textContent || '{}'); + } plugins.forEach(function (plugin) { - if (plugin[1].plugin_type === 'TextPlugin') { + if (plugin[1].type === 'plugin' || plugin[1].type === 'generic') { const url = plugin[1].urls.edit_plugin; const id = plugin[1].plugin_id; - const elements = document.querySelectorAll('.cms-plugin.cms-plugin-' + id); - let wrapper; - - if (elements.length > 0) { - if (elements.length === 1 && elements[0].tagName === 'DIV') { // already wrapped? - wrapper = elements[0]; - wrapper.classList.add('cms-editor-inline-wrapper'); - } else { // no, wrap now! - wrapper = document.createElement('div'); - wrapper.classList.add('cms-editor-inline-wrapper', 'wrapped'); - wrapper = this._wrapAll(elements, wrapper); - wrapper.classList.add('cms-plugin', 'cms-plugin-' + id); - for (let child of wrapper.children) { - child.classList.remove('cms-plugin', 'cms-plugin-' + id); + let editorElements = []; + + if (plugin[1].plugin_type === 'TextPlugin') { + const elements = document.querySelectorAll('.cms-plugin.cms-plugin-' + id); + editorElements = this._initInlineRichText(elements, url, id); + } else if (plugin[1].type === 'generic') { + editorElements = document.getElementsByClassName(plugin[0]); + const edit_fields = new URL(url.replace('&', '&'), 'https://random-base.org') + .searchParams.get('edit_fields'); + if (edit_fields && edit_fields.indexOf(',') === -1 && edit_fields !== 'changelist') { + const generic_class = plugin[0].split('-'); + const search_key = `${generic_class[2]}-${generic_class[3]}-${edit_fields}`; + console.log(search_key); + console.log(generic_inline_fields) ; + if (generic_inline_fields[search_key]) { + this._initInlineGeneric( + editorElements, url, id, edit_fields, generic_inline_fields[search_key], plugin[1].onClose + ); } } - wrapper.dataset.cmsEditUrl = url; - wrapper.dataset.cmsPluginId = id; + } + if (editorElements) { // Catch CMS single click event to highlight the plugin // Catch CMS double click event if present, since double click is needed by Editor - if (this.CMS) { - this.CMS.$(wrapper).on('dblclick.cms-editor', function (event) { - event.stopPropagation(); - }); - wrapper.addEventListener('focusin.cms-editor', () => { - this._highlightTextplugin(id); - }, true); + for (let wrapper of editorElements) { + this.observer.observe(wrapper); + if (this.CMS) { + this.CMS.$(wrapper).on('dblclick.cms-editor', function (event) { + event.stopPropagation(); + }); + wrapper.addEventListener('focusin.cms-editor', () => { + this._highlightTextplugin(id); + }, true); + } } // Prevent tooltip on hover @@ -170,8 +193,6 @@ class CMSEditor { this.CMS.API.Tooltip.displayToggle(false, event.target, '', id); }, 0); }); - - this.observer.observe(wrapper); } } }, this); @@ -185,6 +206,44 @@ class CMSEditor { }); } + _initInlineRichText(elements, url, id) { + let wrapper; + + if (elements.length > 0) { + if (elements.length === 1 && elements[0].tagName === 'DIV' || elements[0].tagName === 'CMS-PLUGIN') { + // already wrapped? + wrapper = elements[0]; + wrapper.classList.add('cms-editor-inline-wrapper'); + } else { // no, wrap now! + wrapper = document.createElement('div'); + wrapper.classList.add('cms-editor-inline-wrapper', 'wrapped'); + wrapper = this._wrapAll(elements, wrapper); + wrapper.classList.add('cms-plugin', 'cms-plugin-' + id); + for (let child of wrapper.children) { + child.classList.remove('cms-plugin', 'cms-plugin-' + id); + } + } + wrapper.dataset.cmsEditUrl = url; + wrapper.dataset.cmsPluginId = id; + wrapper.dataset.cmsType = 'TextPlugin'; + + return [wrapper]; + } + return undefined; + } + + _initInlineGeneric(elements, url, id, edit_field, field_type, onClose) { + for (let el of elements) { + el.dataset.cmsEditUrl = url; + el.dataset.cmsCsrfToken = this.CMS.config.csrf; + el.dataset.onClose = onClose; + el.dataset.cmsField = edit_field; + el.dataset.cmsType = field_type; + el.dataset.settings = 'htmlfield-inline-config'; + } + return elements; + } + /** * Retrieves the settings for the given editor. * If the element is a string, it will be treated as an element's ID. @@ -249,10 +308,20 @@ class CMSEditor { // CMS Editor: destroy destroyAll() { while (this._editors.length) { - window.cms_editor_plugin.destroyEditor(this._editors.pop()); + const el = this._editors.pop(); + this.destroyGenericEditor(el); + window.cms_editor_plugin.destroyEditor(el); } } + // CMS Editor: destroyGenericEditor + destroyGenericEditor (el) { + if (el in this._generic_editors) { + this._generic_editors[el].destroy(); + delete this._generic_editors[el]; + this._generic_editors.pop(el); + } + } saveData(el, action) { if (el && el.dataset.changed === "true") { @@ -261,20 +330,27 @@ class CMSEditor { let url = el.dataset.cmsEditUrl; let csrf = el.dataset.cmsCsrfToken; + let field = el.dataset.cmsField; if (this.CMS) { this.CMS.API.Toolbar.showLoader(); url = this.CMS.API.Helpers.updateUrlWithPath(url); csrf = this.CMS.config.csrf; } + let data = { + csrfmiddlewaretoken: csrf, + _save: 'Save' + }; + if (field) { + data[field] = el.textContent; + } else { + data.body = html; + data.json = JSON.stringify(json) || ''; + } + fetch(url, { method: 'POST', - body: new URLSearchParams({ - csrfmiddlewaretoken: csrf, - body: html, - json: JSON.stringify(json) || '', - _save: 'Save' - }), + body: new URLSearchParams(data), }) .then(response => { el.dataset.changed = 'false'; @@ -286,11 +362,14 @@ class CMSEditor { } return response.text(); }).then(body => { - // Read the CMS databridge values from the response, either directly or from a script tag or - // from the response using regex. + // If the edited field does not force a reload, read the CMS databridge values from the response, + // either directly or from a script tag or from the response using regex. // This depends on the exact format django CMS core returns it. This will need to be adjusted // if the format changes. // Fallback solution is to reload the page as djagocms-text-ckeditor used to do. + if (el.dataset.onClose) { + this.CMS.API.Helpers.reloadBrowser(el.dataset.onClose); + } const dom = document.createElement('div'); dom.innerHTML = body; const script = dom.querySelector('script#data-bridge'); @@ -303,8 +382,7 @@ class CMSEditor { this.CMS.API.Helpers.dataBridge = JSON.parse(regex1[1]); this.CMS.API.Helpers.dataBridge.structure = JSON.parse(regex2[1]); } else { - // No databridge found - // Reload + // No databridge found: reload this.CMS.API.Helpers.reloadBrowser('REFRESH_PAGE'); return; } diff --git a/private/js/cms.texteditor.js b/private/js/cms.texteditor.js new file mode 100644 index 00000000..595f83c0 --- /dev/null +++ b/private/js/cms.texteditor.js @@ -0,0 +1,95 @@ +/* eslint-env es6 */ +/* jshint esversion: 6 */ +/* global document, window, console */ + +class CmsTextEditor { + constructor (el, options, save_callback) { + this.el = el; + this.plugin_identifier = this.find_plugin_identifier(); + const id_split = this.plugin_identifier.split('-'); + this.plugin_id = parseInt(id_split[id_split.length-1]); + this.options = options; + this.events = {}; + this.save = (el) => { + save_callback(el); + }; + this.init(); + } + + destroy () { + this.el.removeEventListener('focus', this._focus.bind(this)); + this.el.removeEventListener('blur', this._blur.bind(this)); + this.el.removeEventListener('input', this._change); + this.el.removeEventListener('keydown', this._key_down); + this.el.removeEventListener('paste', this._paste); + this.el.setAttribute('contenteditable', 'false'); + } + + init () { + this.el.setAttribute('contenteditable', 'plaintext-only'); + if (!this.el.isContentEditable) { + this.el.setAttribute('contenteditable', 'true'); + this.options.enforcePlaintext = true; + + } + this.el.setAttribute('spellcheck', this.options.spellcheck || 'false'); + this.el.addEventListener('input', this._change); + this.el.addEventListener('focus', this._focus.bind(this)); + this.el.addEventListener('blur', this._blur.bind(this)); + this.el.addEventListener('keydown', this._key_down); + if (this.options.enforcePlaintext) { + this.el.addEventListener('paste', this._paste); + } + } + + _key_down (e) { + if (e.key === 'Enter') { + e.preventDefault(); + e.target.blur(); + } + if (e.key === 'Escape') { + e.preventDefault(); + e.target.innerText = e.target.dataset.undo; + e.target.blur(); + } + } + + _focus (e) { + this.options.undo = this.el.innerText; + } + + _blur (e) { + if (this.el.innerText !== this.options.undo) { + this.save(e.target); + } + } + + _paste (e) { + // Upon past only take the plain text + e.preventDefault(); + let text = e.clipboardData.getData('text/plain'); + if (text) { + const [start, end] = [e.target.selectionStart, this.el.selectionEnd]; + e.target.setRangeText(text, start, end, 'select'); + } + } + + _change (e) { + e.target.dataset.changed = 'true'; + } + find_plugin_identifier () { + const header = 'cms-plugin-'; + + for (let cls of this.el.classList) { + if (cls.startsWith(header)) { + let items = cls.substring(header.length).split('-'); + if (items.length === 4 && items[items.length-1] == parseInt(items[items.length-1])) { + return items.join('-'); + } + } + } + return null; + } +} + +export { CmsTextEditor as default }; diff --git a/private/js/cms.tiptap.js b/private/js/cms.tiptap.js index 048f8a71..d133e626 100644 --- a/private/js/cms.tiptap.js +++ b/private/js/cms.tiptap.js @@ -157,7 +157,10 @@ class CMSTipTapPlugin { * @return {string} - The HTML content of the specified editor element. */ getHTML(el) { - return this._editors[el.id].getHTML(); + if (el.id in this._editors) { + return this._editors[el.id].getHTML(); + } + return undefined; } /** @@ -169,7 +172,10 @@ class CMSTipTapPlugin { * @return {Object} - The JSON representation of the element. */ getJSON(el) { - return this._editors[el.id].getJSON(); + if (el.id in this._editors) { + return this._editors[el.id].getJSON(); + } + return undefined; } /** @@ -183,8 +189,10 @@ class CMSTipTapPlugin { if (document.getElementById(el.id + '_editor')) { document.getElementById(el.id + '_editor').remove(); } - this._editors[el.id].destroy(); - delete this._editors[el.id]; + if (el.id in this._editors) { + this._editors[el.id].destroy(); + delete this._editors[el.id]; + } } // transforms the textarea into a div, and returns the div diff --git a/private/js/tiptap_plugins/cms.plugin.js b/private/js/tiptap_plugins/cms.plugin.js index 932cf284..8c927308 100644 --- a/private/js/tiptap_plugins/cms.plugin.js +++ b/private/js/tiptap_plugins/cms.plugin.js @@ -99,7 +99,6 @@ function renderCmsPluginMenu(editor, item, filter) { dropdown += `${module}`; } dropdown += ``; - console.log(plugin); } return `${icon}`;