Great websites : %(_tag_child_1)s and %(_tag_child_2)s
' - }, - 'children':[ - { - 'plugin_type':'LinkPlugin', - 'values':{ - 'name':'django', - 'url':'https://www.djangoproject.com/' - }, - }, - { - 'plugin_type':'LinkPlugin', - 'values':{ - 'name':'django-cms', - 'url':'https://www.django-cms.org' - }, - }, - ] - }, - ] - } - } - - .. _CMS_PLACEHOLDER_CONF: http://docs.django-cms.org/en/latest/how_to/placeholders.html?highlight=cms_placeholder_conf - - - CKEDITOR_SETTINGS - ***************** - - You can override the setting ``CKEDITOR_SETTINGS`` in your settings.py:: - - CKEDITOR_SETTINGS = { - 'language': '{{ language }}', - 'toolbar': 'CMS', - 'skin': 'moono-lisa', - } - - This is the default dict that holds all **CKEditor** settings. - - - Customizing plugin editor - ######################### - - To customize the plugin editor, use `toolbar_CMS` attribute, as in:: - - CKEDITOR_SETTINGS = { - 'language': '{{ language }}', - 'toolbar_CMS': [ - ['Undo', 'Redo'], - ['cmsplugins', '-', 'ShowBlocks'], - ['Format', 'Styles'], - ], - 'skin': 'moono-lisa', - } - - - Customizing HTMLField editor - ############################ - - If you use ``HTMLField`` from ``djangocms_text_ckeditor.fields`` in your own - models, use `toolbar_HTMLField` attribute:: - - CKEDITOR_SETTINGS = { - 'language': '{{ language }}', - 'toolbar_HTMLField': [ - ['Undo', 'Redo'], - ['ShowBlocks'], - ['Format', 'Styles'], - ], - 'skin': 'moono-lisa', - } - - - You can further customize each `HTMLField` field by using different - configuration parameter in your settings:: - - models.py - - class Model1(models.Model): - text = HTMLField(configuration='CKEDITOR_SETTINGS_MODEL1') - - class Model2(models.Model): - text = HTMLField(configuration='CKEDITOR_SETTINGS_MODEL2') - - settings.py - - CKEDITOR_SETTINGS_MODEL1 = { - 'toolbar_HTMLField': [ - ['Undo', 'Redo'], - ['ShowBlocks'], - ['Format', 'Styles'], - ['Bold', 'Italic', 'Underline', '-', 'Subscript', 'Superscript', '-', 'RemoveFormat'], - ] - } - - CKEDITOR_SETTINGS_MODEL2 = { - 'toolbar_HTMLField': [ - ['Undo', 'Redo'], - ['Bold', 'Italic', 'Underline', '-', 'Subscript', 'Superscript', '-', 'RemoveFormat'], - ] - } - - #. Add `configuration='MYSETTING'` to the `HTMLField` usage(s) you want to - customize; - #. Define a setting parameter named as the string used in the `configuration` - argument of the `HTMLField` instance with the desired configuration; - - Values not specified in your custom configuration will be taken from the global - ``CKEDITOR_SETTINGS``. - - For an overview of all the available settings have a look here: - - http://docs.ckeditor.com/#!/api/CKEDITOR.config - - - Inline preview - -------------- - - The child plugins of TextPlugin can be rendered directly inside CKEditor if - ``text_editor_preview`` isn't ``False``. However there are few important points - to note: - - - by default CKEditor doesn't load CSS of your project inside the editing area - and has specific settings regarding empty tags, which could mean that things - will not look as they should until CKEditor is configured correctly. - - See examples: - - - `add styles and js configuration`_ - - `stop CKEditor from removing empty spans`_ (useful for iconfonts) - - - if you override widget default behaviour - be aware that it requires the - property "`allowedContent`_" `to contain`_ ``cms-plugin[*]`` as this custom tag is - what allows the inline previews to be rendered - - - Important note: please avoid html tags in ``__str__`` representation of text - enabled plugins - this messes up inline preview. - - - If you're adding a Text Plugin as a child inside another plugin and want to style it - conditionally based on the parent - you can add ``CMSPluginBase.child_ckeditor_body_css_class`` - attribute to the parent class. - - .. _add styles and js configuration: https://github.com/divio/django-cms-demo/blob/7a104acaa749c52a8ed4870a74898e38daf20e46/src/settings.py#L318-L324 - .. _stop CKEditor from removing empty spans: https://github.com/divio/django-cms-explorer/blob/908a88afa4e1d1176e267e77eb5c61e31ef0f9e5/static/js/addons/ckeditor.wysiwyg.js#L73 - .. _allowedContent: http://docs.ckeditor.com/#!/guide/dev_allowed_content_rules - .. _to contain: https://github.com/django-cms/djangocms-text-ckeditor/issues/405#issuecomment-276814197 - - - Drag & Drop Images - ------------------ - - In IE and Firefox based browsers it is possible to drag and drop a picture into the text editor. - This image is base64 encoded and lives in the 'src' attribute as a 'data' tag. - - We detect this images, encode them and convert them to picture plugins. - If you want to overwrite this behavior for your own picture plugin: - - There is a setting called:: - - TEXT_SAVE_IMAGE_FUNCTION = 'djangocms_text_ckeditor.picture_save.create_picture_plugin' - - you can overwrite this setting in your settings.py and point it to a function that handles image saves. - Have a look at the function ``create_picture_plugin`` for details. - - To completely disable the feature, set ``TEXT_SAVE_IMAGE_FUNCTION = None``. - - - Usage as a model field - ---------------------- - - If you want to use the widget on your own model fields, you can! Just import the provided ``HTMLField`` like so:: - - from djangocms_text_ckeditor.fields import HTMLField - - And use it in your models, just like a ``TextField``:: - - class MyModel(models.Model): - myfield = HTMLField(blank=True) - - This field does not allow you to embed any other CMS plugins within the text editor. Plugins can only be embedded - within ``Placeholder`` fields. - - If you need to allow additional plugins to be embedded in a HTML field, convert the ``HTMLField`` to a ``Placeholderfield`` - and configure the placeholder to only accept TextPlugin. For more information on using placeholders outside of the CMS see: - - http://docs.django-cms.org/en/latest/how_to/placeholders.html - - - Auto Hyphenate Text - ------------------- - - You can hyphenate the text entered into the editor, so that the HTML entity ```` (soft-hyphen_) - automatically is added in between words, at the correct syllable boundary. - - To activate this feature, ``pip install django-softhyphen``. In ``settings.py`` add ``'softhyphen'`` - to the list of ``INSTALLED_APPS``. django-softhyphen_ also installs hyphening dictionaries for 25 - natural languages. - - In case you already installed ``django-softhyphen`` but do not want to soft hyphenate, set - ``TEXT_AUTO_HYPHENATE`` to ``False``. - - .. _soft-hyphen: http://www.w3.org/TR/html4/struct/text.html#h-9.3.3 - .. _django-softhyphen: https://github.com/datadesk/django-softhyphen - - - Extending the plugin - -------------------- - - .. NOTE:: - Added in version 2.0.1 - - You can use this plugin as base to create your own CKEditor-based plugins. - - You need to create your own plugin model extending ``AbstractText``:: - - from djangocms_text_ckeditor.models import AbstractText - - class MyTextModel(AbstractText): - title = models.CharField(max_length=100) - - and a plugin class extending ``TextPlugin`` class:: - - from djangocms_text_ckeditor.cms_plugins import TextPlugin - from .models import MyTextModel - - - class MyTextPlugin(TextPlugin): - name = _(u"My text plugin") - model = MyTextModel - - plugin_pool.register_plugin(MyTextPlugin) - - Note that if you override the `render` method that is inherited from the base ``TextPlugin`` class, any child text - plugins will not render correctly. You must call the super ``render`` method in order for ``plugin_tags_to_user_html()`` - to render out all child plugins located in the ``body`` field. For example:: - - from djangocms_text_ckeditor.cms_plugins import TextPlugin - from .models import MyTextModel - - - class MyTextPlugin(TextPlugin): - name = _(u"My text plugin") - model = MyTextModel - - def render(self, context, instance, placeholder): - context.update({ - 'name': instance.name, - }) - # Other custom render code you may have - return super().render(context, instance, placeholder) - - plugin_pool.register_plugin(MyTextPlugin) - - You can further `customize your plugin`_ as other plugins. - - .. _customize your plugin: http://docs.django-cms.org/en/latest/how_to/custom_plugins.html - - - Adding plugins to the "CMS Plugins" dropdown - -------------------------------------------- - - If you have created a plugin that you want to use within Text plugins you can make them appear in the dropdown by - making them `text_enabled`. This means that you assign the property ``text_enabled`` of a plugin to ``True``, - the default value is `False`. Here is a very simple implementation:: - - class MyTextPlugin(TextPlugin): - name = "My text plugin" - model = MyTextModel - text_enabled = True - - When the plugin is picked up, it will be available in the *CMS Plugins* dropdown, which you can find in the editor. - This makes it very easy for users to insert special content in a user-friendly Text block, which they are familiair with. - - The plugin will even be previewed in the text editor. **Pro-tip**: make sure your plugin provides its own `icon_alt` method. - That way, if you have many `text_enabled`-plugins, it can display a hint about it. For example, if you created a plugin which displays prices of configurable product, it can display a tooltip with the name of that product. - - For more information about extending the CMS with plugins, read `django-cms doc`_ on how to do this. - - .. _django-cms doc: http://docs.django-cms.org/en/latest/reference/plugins.html#cms.plugin_base.CMSPluginBase.text_enabled - - - Configurable sanitizer - ---------------------- - - ``djangocms-text-ckeditor`` uses `html5lib`_ to sanitize HTML to avoid - security issues and to check for correct HTML code. - Sanitisation may strip tags usesful for some use cases such as ``iframe``; - you may customize the tags and attributes allowed by overriding the - ``TEXT_ADDITIONAL_TAGS`` and ``TEXT_ADDITIONAL_ATTRIBUTES`` settings:: - - TEXT_ADDITIONAL_TAGS = ('iframe',) - TEXT_ADDITIONAL_ATTRIBUTES = ('scrolling', 'allowfullscreen', 'frameborder') - - In case you need more control on sanitisation you can extend AllowTokenParser class and define - your logic into parse() method. For example, if you want to skip your donut attribute during - sanitisation, you can create a class like this:: - - from djangocms_text_ckeditor.sanitizer import AllowTokenParser - - - class DonutAttributeParser(AllowTokenParser): - - def parse(self, attribute, val): - return attribute.startswith('donut-') - - And add your class to ``ALLOW_TOKEN_PARSERS`` settings:: - - ALLOW_TOKEN_PARSERS = ( - 'mymodule.DonutAttributeParser', - ) - - **NOTE**: Some versions of CKEditor will pre-sanitize your text before passing it to the web server, - rendering the above settings useless. To ensure this does not happen, you may need to add the - following parameters to ``CKEDITOR_SETTINGS``:: - - ... - 'basicEntities': False, - 'entities': False, - ... - - To completely disable the feature, set ``TEXT_HTML_SANITIZE = False``. - - See the `html5lib documentation`_ for further information. - - .. _html5lib: https://pypi.python.org/pypi/html5lib - .. _html5lib documentation: https://code.google.com/p/html5lib/wiki/UserDocumentation#Sanitizing_Tokenizer - - - Search - ------ - - djangocms-text-ckeditor works well with `aldryn-search${message.message}
`; + } + if (message_text.length > 0) { + this.CMS.API.Messages.open({ + message: message_text, + error: error === "error", + }); + } + }); + } + + } const script = dom.querySelector('script#data-bridge'); - if (script) { + el.dataset.changed = 'false'; + if (script && script.textContent.length > 2) { this.CMS.API.Helpers.dataBridge = JSON.parse(script.textContent); } else { const regex1 = /^\s*Window\.CMS\.API\.Helpers\.dataBridge\s=\s(.*?);$/gmu.exec(body); @@ -303,17 +445,19 @@ 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; } } this.CMS.API.StructureBoard.handleEditPlugin(this.CMS.API.Helpers.dataBridge); - this._loadToolbar(); + if (this.CMS.settings.version < "4") { + /* Reflect dirty flag in django CMS < 4 */ + this._loadToolbar(); + } }) .catch(error => { - el.dataset.changed = 'true'; + el.dataset.changed = 'true'; if (this.CMS) { this.CMS.API.Toolbar.hideLoader(); this.CMS.API.Messages.open({ @@ -373,7 +517,7 @@ class CMSEditor { } // - let saveSuccess = !!form.querySelector('.messagelist :not(.error)'); + let saveSuccess = !!form.querySelector('div.messagelist div.success'); if (!saveSuccess) { saveSuccess = !!form.querySelector('.dashboard #content-main') && @@ -505,6 +649,7 @@ class CMSEditor { } } + // Create global editor object window.CMS_Editor = new CMSEditor(); diff --git a/private/js/cms.linkfield.js b/private/js/cms.linkfield.js index 081ea907..b2026cbb 100644 --- a/private/js/cms.linkfield.js +++ b/private/js/cms.linkfield.js @@ -6,12 +6,10 @@ import "../css/cms.linkfield.css"; class LinkField { constructor(element, options) { - console.log("LinkField constructor", element.id + '_select'); this.options = options; this.urlElement = element; this.form = element.closest("form"); this.selectElement = this.form.querySelector(`input[name="${this.urlElement.id + '_select'}"]`); - console.log(this.urlElement, this.selectElement); if (this.selectElement) { this.prepareField(); this.registerEvents(); @@ -108,7 +106,6 @@ class LinkField { handleSelection(event) { event.stopPropagation(); event.preventDefault(); - console.log("handleSelection", event.target.textContent, event.target.getAttribute('data-href')); this.inputElement.value = event.target.getAttribute('data-text') || event.target.textContent; this.inputElement.classList.add('cms-linkfield-selected'); this.urlElement.value = event.target.getAttribute('data-href'); @@ -118,7 +115,6 @@ class LinkField { } handleChange(event) { - console.log("handleChange", event.target); if (this.selectElement.value) { fetch(this.options.url + '?g=' + encodeURIComponent(this.selectElement.value)) .then(response => response.json()) diff --git a/private/js/cms.texteditor.js b/private/js/cms.texteditor.js new file mode 100644 index 00000000..0d3bdb5b --- /dev/null +++ b/private/js/cms.texteditor.js @@ -0,0 +1,103 @@ +/* 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 = save_callback; + 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(); + if (e.target.dataset.undo) { + e.target.innerText = e.target.dataset.undo; + e.target.dataset.changed = false; + } + e.target.blur(); + } + } + + _focus (e) { + this.options.undo = this.el.innerText; + } + + _blur (e) { + this.save(e.target, (el, response) => { + setTimeout(() => { + if (e.target.dataset.changed === 'true') { + e.target.innerText = this.options.undo; + e.target.dataset.changed = 'false'; + e.target.focus(); + } + }, 100); + }); + } + + _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..39354c22 100644 --- a/private/js/cms.tiptap.js +++ b/private/js/cms.tiptap.js @@ -15,7 +15,7 @@ import TableCell from '@tiptap/extension-table-cell' import TableHeader from '@tiptap/extension-table-header' import TableRow from '@tiptap/extension-table-row' import { TextAlign, TextAlignOptions } from '@tiptap/extension-text-align'; -import CmsPluginNode from './tiptap_plugins/cms.plugin'; +import { CmsPluginNode, CmsBlockPluginNode } from './tiptap_plugins/cms.plugin'; import TiptapToolbar from "./tiptap_plugins/cms.tiptap.toolbar"; import {StarterKit} from "@tiptap/starter-kit"; @@ -55,6 +55,7 @@ class CMSTipTapPlugin { }), Small, Var, Kbd, Samp, CmsPluginNode, + CmsBlockPluginNode, TextAlign.configure({ types: ['heading', 'paragraph'], }), @@ -114,6 +115,7 @@ class CMSTipTapPlugin { const editor = new Editor({ extensions: options.extensions, + autofocus: false, content: content || '', editable: true, element: editorElement, @@ -136,10 +138,12 @@ class CMSTipTapPlugin { } }); this._editors[el.id] = editor; - if (el.tagName === 'TEXTAREA') { + const el_rect = el.getBoundingClientRect(); + + if (el.tagName === 'TEXTAREA' || el_rect.x < 32) { // Not inline this._createTopToolbar(editorElement, editor, options); - if (el.rows) { + if (el.rows && !el.closest('body.app-djangocms_text.change-form')) { editorElement.querySelector('.tiptap').style.height = el.rows * 1.5 + 'em'; } } else { @@ -157,7 +161,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 +176,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 +193,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 @@ -214,7 +226,7 @@ class CMSTipTapPlugin { } _createBlockToolbar(el, editor, options) { - const toolbar = this._populateToolbar(editor,options.toolbar || this.settings.toolbar_HTMLField, 'block'); + const toolbar = this._populateToolbar(editor,options.toolbar || this.options.toolbar_HTMLField, 'block'); const ballonToolbar = new CmsBalloonToolbar(editor, toolbar, (event) => this._handleToolbarClick(event, editor), (el) => this._updateToolbar(editor, el)); @@ -283,8 +295,6 @@ class CMSTipTapPlugin { // Limit its width to the available space toolbarElement.style.maxWidth = (window.innerWidth - toolbarElement.getBoundingClientRect().left - 16) + 'px'; - } else { - editor.commands.focus('start'); } // 4.Document if a selection is in progress editor.view.dom.addEventListener('dblclick', (e) => this._handleDblClick(e, editor)); @@ -369,6 +379,9 @@ class CMSTipTapPlugin { let html = ''; for (let item of array) { + if (item === undefined) { + continue; + } if (item in TiptapToolbar && TiptapToolbar[item].insitu) { item = TiptapToolbar[item].insitu; } else if (item in TiptapToolbar && TiptapToolbar[item].items) { diff --git a/private/js/tiptap_plugins/cms.balloon-toolbar.js b/private/js/tiptap_plugins/cms.balloon-toolbar.js index 3f7a0772..4af295d7 100644 --- a/private/js/tiptap_plugins/cms.balloon-toolbar.js +++ b/private/js/tiptap_plugins/cms.balloon-toolbar.js @@ -37,7 +37,7 @@ export default class CmsBalloonToolbar { this.form = null; this.toolbar = document.createElement('div'); this.toolbar.classList.add('cms-balloon'); - this.toolbar.style.zIndex = editor.options.baseFloatZIndex || 10000000; // + this.toolbar.style.zIndex = editor.options.baseFloatZIndex || 1000000; // this.toolbar.innerHTML = this._menu_icon; editor.options.el.prepend(this.toolbar); @@ -82,7 +82,6 @@ export default class CmsBalloonToolbar { this.form.open(); } }); - this.ref = editor.options.el.getBoundingClientRect(); editor.on('selectionUpdate', () => this._showToolbar()); editor.on('blur', () => this._showToolbar()); editor.on('destroy', () => this.remove()); @@ -117,14 +116,20 @@ export default class CmsBalloonToolbar { } depth -= 1; } - depth = 1; // TODO: Decide which works better: First level, or highest level with block node - this._updateToolbarIcon(resolvedPos.node(depth)); - const startPos = resolvedPos.start(depth); - this.toolbar.dataset.block = startPos; - const pos = this.editor.view.coordsAtPos(startPos); - this.toolbar.style.insetBlockStart = `${pos.top + window.scrollY - this.ref.top}px`; + if (depth > 0) { + this._updateToolbarIcon(resolvedPos.node(depth)); + const startPos = resolvedPos.start(depth); + this.toolbar.dataset.block = startPos; + const pos = this.editor.view.coordsAtPos(startPos); + const ref = this.editor.options.el.getBoundingClientRect(); + + this.toolbar.style.insetBlockStart = `${pos.top - ref.top}px`; + this.toolbar.style.display = 'block'; + } else { + this.toolbar.style.display = 'none'; + } // TODO: Set the size of the balloon according to the fontsize - // this.toolbar.style.setProperty('--size', this.editor.view. ...) + // this.toolbar.style.setProperty('--size', this.editor.view. ...) } _getResolvedPos() { @@ -141,7 +146,6 @@ export default class CmsBalloonToolbar { if (type in this._node_icons) { this.toolbar.innerHTML = this._node_icons[type]; } else { - console.log(type); this.toolbar.innerHTML = this._menu_icon; } } diff --git a/private/js/tiptap_plugins/cms.plugin.js b/private/js/tiptap_plugins/cms.plugin.js index 932cf284..f280926a 100644 --- a/private/js/tiptap_plugins/cms.plugin.js +++ b/private/js/tiptap_plugins/cms.plugin.js @@ -1,11 +1,26 @@ /* eslint-env es6 */ -/* jshint esversion: 6 */ +/* jshint esversion: 9 */ /* global document, window, console */ import { Node } from '@tiptap/core'; import CmsDialog from "../cms.dialog.js"; import TiptapToolbar from "./cms.tiptap.toolbar"; +const blockTags = ((str) => str.toUpperCase().substring(1, str.length-1).split("><"))( + "