├── wagtail_editorjs ├── test │ ├── __init__.py │ ├── testapp │ │ ├── __init__.py │ │ ├── asgi.py │ │ ├── wsgi.py │ │ ├── urls.py │ │ └── settings.py │ ├── core │ │ ├── tests │ │ │ ├── __init__.py │ │ │ ├── test_inlines.py │ │ │ ├── base.py │ │ │ ├── test_attrs.py │ │ │ ├── test_blocks.py │ │ │ └── test_render.py │ │ ├── migrations │ │ │ └── __init__.py │ │ ├── __init__.py │ │ └── apps.py │ ├── simple.md │ └── manage.py ├── migrations │ └── __init__.py ├── templatetags │ ├── __init__.py │ └── editorjs.py ├── wagtail_hooks │ ├── __init__.py │ ├── urls.py │ └── wagtail_fedit.py ├── static │ └── wagtail_editorjs │ │ ├── js │ │ ├── init │ │ │ ├── drag-drop.js │ │ │ └── undo-redo.js │ │ ├── tools │ │ │ ├── attaches.js │ │ │ ├── tooltips │ │ │ │ ├── frontend.js │ │ │ │ └── wagtail-tooltips.js │ │ │ ├── wagtail-document.js │ │ │ ├── wagtail-chooser-tool.js │ │ │ ├── wagtail-block.js │ │ │ ├── wagtail-inline-tool.js │ │ │ ├── wagtail-button-tool.js │ │ │ └── wagtail-link.js │ │ ├── editorjs-widget-controller.js │ │ ├── editorjs-block.js │ │ └── editorjs-widget.js │ │ └── vendor │ │ ├── tippy │ │ └── LICENSE │ │ └── editorjs │ │ └── tools │ │ ├── delimiter.js │ │ ├── inline-code.js │ │ ├── marker.js │ │ ├── paragraph.umd.js │ │ ├── raw.js │ │ ├── code.js │ │ ├── warning.js │ │ ├── quote.js │ │ ├── header.js │ │ ├── checklist.js │ │ └── underline.js ├── templates │ └── wagtail_editorjs │ │ ├── rich_text.html │ │ └── widgets │ │ └── editorjs.html ├── apps.py ├── registry │ ├── element │ │ ├── __init__.py │ │ ├── utils.py │ │ ├── attrs.py │ │ └── element.py │ ├── features │ │ ├── __init__.py │ │ ├── snippets_inlines.py │ │ ├── view.py │ │ └── basic.py │ ├── __init__.py │ └── value.py ├── hooks.py ├── __init__.py ├── features │ ├── __init__.py │ ├── tunes.py │ ├── lists.py │ └── documents.py ├── settings.py ├── fields.py ├── blocks.py ├── django_editor.py ├── render.py └── forms.py ├── docs ├── inlines.md ├── custom_feature.md ├── examples │ ├── editorjs_tunes_example.py │ └── editorjs_feature_example.py ├── tunes.md └── editorjs_feature.md ├── setup.py ├── pyproject.toml ├── MANIFEST.in ├── .github ├── workflows │ └── main.yml └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── setup.cfg ├── .GITIGNORE ├── push-to-github.ps1 └── README.md /wagtail_editorjs/test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/inlines.md: -------------------------------------------------------------------------------- 1 | # To be added. 2 | -------------------------------------------------------------------------------- /docs/custom_feature.md: -------------------------------------------------------------------------------- 1 | # To be added. 2 | -------------------------------------------------------------------------------- /wagtail_editorjs/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /wagtail_editorjs/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /wagtail_editorjs/test/testapp/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /wagtail_editorjs/test/core/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() -------------------------------------------------------------------------------- /wagtail_editorjs/test/core/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ['setuptools>=40.8.0'] 3 | build-backend = 'setuptools.build_meta' -------------------------------------------------------------------------------- /wagtail_editorjs/wagtail_hooks/__init__.py: -------------------------------------------------------------------------------- 1 | from .features import * 2 | from .urls import * 3 | from .wagtail_fedit import * -------------------------------------------------------------------------------- /wagtail_editorjs/static/wagtail_editorjs/js/init/drag-drop.js: -------------------------------------------------------------------------------- 1 | window.registerInitializer((widget) => { 2 | new window.DragDrop(widget.editor); 3 | }); -------------------------------------------------------------------------------- /wagtail_editorjs/static/wagtail_editorjs/js/init/undo-redo.js: -------------------------------------------------------------------------------- 1 | window.registerInitializer((widget) => { 2 | new window.Undo({ editor: widget.editor }); 3 | }); -------------------------------------------------------------------------------- /wagtail_editorjs/templates/wagtail_editorjs/rich_text.html: -------------------------------------------------------------------------------- 1 | 2 | {{ html|safe }} 3 | -------------------------------------------------------------------------------- /wagtail_editorjs/test/core/__init__.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.conf import settings 3 | from os import path 4 | 5 | 6 | FIXTURES = path.join(settings.BASE_DIR, "core/fixtures") 7 | -------------------------------------------------------------------------------- /wagtail_editorjs/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class WagtailEditorjsConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'wagtail_editorjs' 7 | -------------------------------------------------------------------------------- /wagtail_editorjs/test/core/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class CoreConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'wagtail_editorjs.test.core' 7 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | recursive-include wagtail_editorjs/static * 4 | recursive-include wagtail_editorjs/templates * 5 | recursive-include wagtail_editorjs/locale * 6 | recursive-include wagtail_editorjs/migrations * 7 | recursive-include wagtail_editorjs/templatetags * -------------------------------------------------------------------------------- /wagtail_editorjs/test/simple.md: -------------------------------------------------------------------------------- 1 | # Run tests 2 | 3 | **Initialize venv** 4 | 5 | `py -m venv ./.venv` 6 | 7 | `./.venv/Scripts/activate` 8 | 9 | **Install relevant packages** 10 | 11 | `py -m pip install wagtail_editorjs django wagtail` 12 | 13 | **Install package to test** 14 | 15 | `pip install -e .` 16 | -------------------------------------------------------------------------------- /wagtail_editorjs/registry/element/__init__.py: -------------------------------------------------------------------------------- 1 | from .attrs import ( 2 | EditorJSElementAttribute, 3 | EditorJSStyleAttribute, 4 | ) 5 | from .utils import ( 6 | wrap_tag, 7 | add_attributes, 8 | ) 9 | from .element import ( 10 | EditorJSElement, 11 | EditorJSSoupElement, 12 | EditorJSWrapper, 13 | wrapper, 14 | ) -------------------------------------------------------------------------------- /wagtail_editorjs/hooks.py: -------------------------------------------------------------------------------- 1 | 2 | """ 3 | Hook used to register features with the editorjs widget. 4 | """ 5 | REGISTER_HOOK_NAME = "register_wagtail_editorjs_features" 6 | 7 | """ 8 | Hook used to build the configuration for the editorjs widget. 9 | Any additional configuration or overrides can be done here. 10 | """ 11 | BUILD_CONFIG_HOOK = "editorjs_widget_build_config" 12 | 13 | -------------------------------------------------------------------------------- /wagtail_editorjs/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | wagtail_editorjs 3 | © 2024 Nigel van Keulen, goodadvice.it 4 | 5 | A Wagtail integration for the Editor.js block editor. 6 | Includes support for custom blocks, inline tools, and more. 7 | Everything is dynamic and customizable. 8 | """ 9 | 10 | import distutils.version as pv 11 | 12 | 13 | 14 | __version__ = 'v1.6.6' 15 | VERSION = pv.LooseVersion(__version__) 16 | -------------------------------------------------------------------------------- /wagtail_editorjs/registry/features/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import ( 2 | PageChooserURLsMixin, 3 | TemplateNotSpecifiedError, 4 | BaseEditorJSFeature, 5 | ) 6 | from .basic import ( 7 | EditorJSFeature, 8 | EditorJSJavascriptFeature, 9 | EditorJSTune, 10 | ) 11 | from .inlines import ( 12 | InlineEditorJSFeature, 13 | ModelInlineEditorJSFeature, 14 | ) 15 | from .view import ( 16 | FeatureViewMixin, 17 | ) -------------------------------------------------------------------------------- /wagtail_editorjs/static/wagtail_editorjs/js/tools/attaches.js: -------------------------------------------------------------------------------- 1 | class CSRFAttachesTool extends window.AttachesTool { 2 | constructor({ data, config, api, readOnly }) { 3 | config['additionalRequestHeaders'] = { 4 | 'X-CSRFToken': document.querySelector('input[name="csrfmiddlewaretoken"]').value 5 | }; 6 | super({ data, config, api, readOnly }); 7 | } 8 | } 9 | 10 | window.CSRFAttachesTool = CSRFAttachesTool; -------------------------------------------------------------------------------- /wagtail_editorjs/test/testapp/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for testapp project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/5.0/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'testapp.settings') 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /wagtail_editorjs/test/testapp/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for testapp project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/5.0/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'wagtail_editorjs.test.testapp.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: test_wagtail_editorjs 2 | on: [pull_request, push] 3 | jobs: 4 | test: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v3 8 | - name: Set up Python ${{ matrix.python }} 9 | uses: actions/setup-python@v4 10 | with: 11 | python-version: ${{ matrix.python }} 12 | cache: 'pip' 13 | - name: Install dependencies 14 | run: | 15 | python -m pip install --upgrade pip 16 | pip install -e . 17 | - name: Run tests 18 | run: | 19 | python wagtail_editorjs/test/manage.py test 20 | -------------------------------------------------------------------------------- /wagtail_editorjs/templates/wagtail_editorjs/widgets/editorjs.html: -------------------------------------------------------------------------------- 1 |
2 | {% include "django/forms/widgets/hidden.html" with widget=widget %} 3 | {{ widget.config|json_script:"wagtail-editorjs-config" }} 4 |
5 | 6 |
7 |
8 | {% for tpl in widget.inclusion_templates %} 9 | {{ tpl|safe }} 10 | {% endfor %} 11 |
12 |
-------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /wagtail_editorjs/wagtail_hooks/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, include 2 | from wagtail import hooks 3 | 4 | 5 | @hooks.register("register_admin_urls") 6 | def register_admin_urls(): 7 | urls = [] 8 | 9 | # Make sure all features are properly registered. 10 | from ..registry import EDITOR_JS_FEATURES 11 | EDITOR_JS_FEATURES._look_for_features() 12 | 13 | for hook in hooks.get_hooks("register_editorjs_urls"): 14 | urls += hook() 15 | 16 | return [ 17 | path( 18 | 'wagtail-editorjs/', 19 | name='wagtail_editorjs', 20 | view=include( 21 | (urls, 'wagtail_editorjs'), 22 | namespace='wagtail_editorjs' 23 | ), 24 | ), 25 | ] 26 | 27 | -------------------------------------------------------------------------------- /wagtail_editorjs/registry/features/snippets_inlines.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from .inlines import ( 3 | ModelInlineEditorJSFeature, 4 | ) 5 | from wagtail.snippets.widgets import AdminSnippetChooser 6 | 7 | 8 | class SnippetChooserModel: 9 | """ Utility class for type annotations """ 10 | 11 | def build_element(self, soup_elem, context = None): ... 12 | 13 | 14 | class BaseInlineSnippetChooserFeature(ModelInlineEditorJSFeature): 15 | model: SnippetChooserModel = None 16 | widget = AdminSnippetChooser 17 | 18 | def build_element(self, soup_elem, obj: SnippetChooserModel, context: dict[str, Any] = None, data: dict[str, Any] = None): 19 | """ Build the element from the object. """ 20 | return obj.build_element(soup_elem, context) 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /wagtail_editorjs/test/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'wagtail_editorjs.test.testapp.settings') 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == '__main__': 22 | main() 23 | -------------------------------------------------------------------------------- /wagtail_editorjs/features/__init__.py: -------------------------------------------------------------------------------- 1 | from .blocks import ( 2 | CodeFeature, 3 | DelimiterFeature, 4 | HeaderFeature, 5 | HTMLFeature, 6 | WarningFeature, 7 | TableFeature, 8 | BlockQuoteFeature, 9 | WagtailBlockFeature, 10 | EditorJSFeatureStructBlock, 11 | ButtonFeature, 12 | ) 13 | from .lists import ( 14 | NestedListFeature, 15 | CheckListFeature, 16 | ) 17 | from .documents import ( 18 | AttachesFeature, 19 | ) 20 | from .images import ( 21 | ImageFeature, 22 | ImageRowFeature, 23 | ) 24 | from .inlines import ( 25 | TooltipFeature, 26 | LinkFeature, 27 | LinkAutoCompleteFeature, 28 | DocumentFeature, 29 | ) 30 | from .tunes import ( 31 | AlignmentBlockTune, 32 | TextVariantTune, 33 | ColorTune, 34 | BackgroundColorTune, 35 | ) -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - Browser [e.g. chrome, safari] 28 | - Version [Version of the package] 29 | 30 | 31 | **Additional context** 32 | Add any other context about the problem here. 33 | -------------------------------------------------------------------------------- /wagtail_editorjs/wagtail_hooks/wagtail_fedit.py: -------------------------------------------------------------------------------- 1 | from wagtail_editorjs.render import render_editorjs_html 2 | from wagtail_editorjs.registry import EditorJSValue 3 | from wagtail_editorjs.fields import EditorJSField 4 | from wagtail import hooks 5 | 6 | 7 | 8 | @hooks.register("wagtail_fedit.register_type_renderer") 9 | def register_renderers(renderer_map): 10 | 11 | # This is a custom renderer for RichText fields. 12 | # It will render the RichText field as a RichText block. 13 | renderer_map[EditorJSValue] = lambda request, context, instance, value: render_editorjs_html( 14 | value.features, 15 | value, 16 | context=context, 17 | ) 18 | 19 | @hooks.register("wagtail_fedit.field_editor_size") 20 | def field_editor_size(model_instance, model_field): 21 | if isinstance(model_field, EditorJSField): 22 | return "full" 23 | return None 24 | -------------------------------------------------------------------------------- /wagtail_editorjs/registry/__init__.py: -------------------------------------------------------------------------------- 1 | from .feature_registry import ( 2 | EditorJSFeatures, 3 | ) 4 | from .features import ( 5 | PageChooserURLsMixin, 6 | BaseEditorJSFeature, 7 | EditorJSFeature, 8 | EditorJSJavascriptFeature, 9 | EditorJSTune, 10 | FeatureViewMixin, 11 | InlineEditorJSFeature, 12 | ModelInlineEditorJSFeature, 13 | TemplateNotSpecifiedError, 14 | ) 15 | from .value import ( 16 | EditorJSBlock, 17 | EditorJSValue, 18 | ) 19 | from .element import ( 20 | EditorJSElement, 21 | EditorJSSoupElement, 22 | EditorJSWrapper, 23 | wrapper, 24 | EditorJSElementAttribute, 25 | EditorJSStyleAttribute, 26 | wrap_tag, 27 | add_attributes, 28 | ) 29 | 30 | def get_features(features: list[str] = None): 31 | if not features: 32 | features = list(EDITOR_JS_FEATURES.keys()) 33 | 34 | for feature in features: 35 | if feature not in EDITOR_JS_FEATURES: 36 | raise ValueError(f"Unknown feature: {feature}") 37 | 38 | return features 39 | 40 | EDITOR_JS_FEATURES = EditorJSFeatures() 41 | 42 | -------------------------------------------------------------------------------- /wagtail_editorjs/settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings as django_settings 2 | 3 | 4 | """ 5 | Clean the HTML output of the EditorJS field using bleach. 6 | This will remove any tags or attributes not allowed by the EditorJS features. 7 | If you want to disable this, set it to False. 8 | 9 | Optionally; cleaning can be FORCED by passing `clean=True` to the `render_editorjs_html` function. 10 | """ 11 | CLEAN_HTML = getattr(django_settings, 'EDITORJS_CLEAN_HTML', True) 12 | 13 | """ 14 | Add a block ID to each editorJS block when rendering. 15 | This is useful for targeting the block with JavaScript, 16 | or possibly creating some link from frontend to admin area. 17 | """ 18 | ADD_BLOCK_ID = getattr(django_settings, 'EDITORJS_ADD_BLOCK_ID', True) 19 | 20 | """ 21 | The attribute name to use for the block ID. 22 | This is only used if `ADD_BLOCK_ID` is True. 23 | """ 24 | BLOCK_ID_ATTR = getattr(django_settings, 'EDITORJS_BLOCK_ID_ATTR', 'data-editorjs-block-id') 25 | 26 | """ 27 | Use full urls if the request 28 | is available in the EditorJS rendering context. 29 | """ 30 | USE_FULL_URLS = getattr(django_settings, 'EDITORJS_USE_FULL_URLS', False) 31 | -------------------------------------------------------------------------------- /wagtail_editorjs/static/wagtail_editorjs/vendor/tippy/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017-present atomiks 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /wagtail_editorjs/test/testapp/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.urls import include, path 3 | from django.contrib import admin 4 | 5 | from wagtail.admin import urls as wagtailadmin_urls 6 | from wagtail import urls as wagtail_urls 7 | from wagtail.documents import urls as wagtaildocs_urls 8 | 9 | urlpatterns = [ 10 | path("django-admin/", admin.site.urls), 11 | path("admin/", include(wagtailadmin_urls)), 12 | path("documents/", include(wagtaildocs_urls)), 13 | ] 14 | 15 | 16 | if settings.DEBUG: 17 | from django.conf.urls.static import static 18 | from django.contrib.staticfiles.urls import staticfiles_urlpatterns 19 | 20 | # Serve static and media files from development server 21 | urlpatterns += staticfiles_urlpatterns() 22 | urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 23 | 24 | urlpatterns = urlpatterns + [ 25 | # For anything not caught by a more specific rule above, hand over to 26 | # Wagtail's page serving mechanism. This should be the last pattern in 27 | # the list: 28 | path("", include(wagtail_urls)), 29 | # Alternatively, if you want Wagtail pages to be served from a subpath 30 | # of your site, rather than the site root: 31 | # path("pages/", include(wagtail_urls)), 32 | ] -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = wagtail_editorjs 3 | version = v1.6.6 4 | description = EditorJS as a widget for Wagtail, with Page- and Image chooser support 5 | long_description = file: README.md 6 | long_description_content_type = text/markdown 7 | author = Nigel 8 | author_email = nigel@goodadvice.it 9 | url = https://github.com/Nigel2392/wagtail_editorjs 10 | license = GPL-3.0-only 11 | classifiers = 12 | Environment :: Web Environment 13 | Framework :: Django 14 | Framework :: Django :: 4.2 15 | Framework :: Wagtail 16 | Framework :: Wagtail :: 5 17 | Framework :: Wagtail :: 6 18 | Intended Audience :: Developers 19 | License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+) 20 | Operating System :: OS Independent 21 | Programming Language :: Python 22 | Programming Language :: Python :: 3 23 | Programming Language :: Python :: 3 :: Only 24 | Programming Language :: Python :: 3.8 25 | Programming Language :: Python :: 3.9 26 | Topic :: Internet :: WWW/HTTP 27 | Topic :: Internet :: WWW/HTTP :: Dynamic Content 28 | 29 | [options] 30 | include_package_data = true 31 | packages = find: 32 | python_requires = >=3.8 33 | install_requires = 34 | Django >= 4.2 35 | Wagtail >= 5.0 36 | beautifulsoup4 >= 4.9.3 37 | bleach >= 6.0.0 38 | -------------------------------------------------------------------------------- /wagtail_editorjs/static/wagtail_editorjs/js/editorjs-widget-controller.js: -------------------------------------------------------------------------------- 1 | class EditorJSWidgetController extends window.StimulusModule.Controller { 2 | static values = { 3 | 4 | }; 5 | 6 | connect() { 7 | 8 | const wrapper = document.querySelector(`#${this.element.id}-wagtail-editorjs-widget-wrapper`); 9 | const configElem = wrapper.querySelector(`#wagtail-editorjs-config`); 10 | const config = JSON.parse(configElem.textContent); 11 | const keys = Object.keys(config.tools); 12 | for (let i = 0; i < keys.length; i++) { 13 | const key = keys[i]; 14 | const toolConfig = config.tools[key]; 15 | const toolClass = window[toolConfig.class]; 16 | toolConfig.class = toolClass; 17 | config.tools[key] = toolConfig; 18 | } 19 | 20 | this.widget = new EditorJSWidget( 21 | wrapper, 22 | this.element, 23 | config, 24 | ); 25 | 26 | wrapper.widget = this.widget; 27 | wrapper.widgetConfig = config; 28 | wrapper.widgetElement = this.element; 29 | } 30 | 31 | disconnect() { 32 | this.widget.disconnect(); 33 | this.widget = null; 34 | } 35 | } 36 | 37 | window.wagtail.app.register('editorjs-widget', EditorJSWidgetController); 38 | -------------------------------------------------------------------------------- /wagtail_editorjs/registry/features/view.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | from django.urls import path 3 | from django.http import HttpRequest, HttpResponseNotAllowed 4 | from wagtail import hooks 5 | 6 | if TYPE_CHECKING: 7 | from ..feature_registry import EditorJSFeatures 8 | 9 | 10 | class FeatureViewMixin: 11 | 12 | def handler(self, request: HttpRequest, *args, **kwargs): 13 | method = request.method.lower() 14 | if not hasattr(self, f"handle_{method}"): 15 | return self.method_not_allowed(request) 16 | 17 | view_func = getattr(self, f"handle_{method}") 18 | return view_func(request, *args, **kwargs) 19 | 20 | def method_not_allowed(self, request: HttpRequest): 21 | methods = ["get", "post", "put", "patch", "delete"] 22 | methods = [m for m in methods if hasattr(self, f"handle_{m}")] 23 | return HttpResponseNotAllowed(methods) 24 | 25 | def get_urlpatterns(self): 26 | return [ 27 | path( 28 | f"{self.tool_name}/", 29 | self.handler, 30 | name=self.tool_name, 31 | ) 32 | ] 33 | 34 | def on_register(self, registry: "EditorJSFeatures"): 35 | super().on_register(registry) 36 | @hooks.register("register_editorjs_urls") 37 | def register_admin_urls(): 38 | return self.get_urlpatterns() 39 | 40 | -------------------------------------------------------------------------------- /wagtail_editorjs/static/wagtail_editorjs/js/editorjs-block.js: -------------------------------------------------------------------------------- 1 | class EditorJSBlock extends window.wagtailStreamField.blocks.FieldBlock { 2 | getTextLabel(opts) { 3 | const rawValue = this.widget.getState(); 4 | const value = JSON.parse(rawValue); 5 | const blocks = value.blocks; 6 | const text = []; 7 | const maxLength = opts.maxLength || 100; 8 | let currentLength = 0; 9 | 10 | for (let i = 0; i < blocks.length; i++) { 11 | const block = blocks[i]; 12 | const data = block.data; 13 | if ("text" in data && data.text) { 14 | currentLength += data.text.length; 15 | text.push(data.text); 16 | } 17 | if (currentLength >= maxLength) { 18 | break; 19 | } 20 | } 21 | 22 | let fakeElement = document.createElement('div'); 23 | fakeElement.innerHTML = text.join(' '); 24 | return fakeElement.textContent; 25 | } 26 | } 27 | 28 | 29 | class EditorJSBlockDefinition extends window.wagtailStreamField.blocks.FieldBlockDefinition { 30 | render(placeholder, prefix, initialState, initialError, parentCapabilities) { 31 | return new EditorJSBlock( 32 | this, 33 | placeholder, 34 | prefix, 35 | initialState, 36 | initialError, 37 | parentCapabilities, 38 | ); 39 | } 40 | } 41 | 42 | 43 | window.telepath.register("wagtail_editorjs.blocks.EditorJSBlock", EditorJSBlockDefinition); 44 | -------------------------------------------------------------------------------- /wagtail_editorjs/templatetags/editorjs.py: -------------------------------------------------------------------------------- 1 | from django import ( 2 | template, 3 | forms, 4 | ) 5 | from ..render import render_editorjs_html 6 | from ..registry import EDITOR_JS_FEATURES, EditorJSValue 7 | from ..forms import _get_feature_scripts 8 | 9 | register = template.Library() 10 | 11 | @register.simple_tag(takes_context=True, name="editorjs") 12 | def render_editorjs(context, data: EditorJSValue): 13 | return render_editorjs_html(data.features, data, context=context) 14 | 15 | 16 | init_scripts = { 17 | "css": [ 18 | 'wagtail_editorjs/css/frontend.css', 19 | ] 20 | } 21 | 22 | 23 | @register.simple_tag(takes_context=False, name="editorjs_static") 24 | def editorjs_static(type_of_script="css", features: list[str] = None): 25 | if type_of_script not in ["css", "js"]: 26 | raise ValueError("type_of_script must be either 'css' or 'js'") 27 | 28 | if features is None: 29 | features = EDITOR_JS_FEATURES.keys() 30 | 31 | feature_mapping = EDITOR_JS_FEATURES.get_by_weight( 32 | features 33 | ) 34 | 35 | frontend_static = init_scripts.get(type_of_script, []) 36 | for feature in feature_mapping.values(): 37 | 38 | frontend_static.extend( 39 | _get_feature_scripts( 40 | feature, 41 | f"get_frontend_{type_of_script}", 42 | list_obj=frontend_static, 43 | ) 44 | ) 45 | 46 | kwargs = {} 47 | if type_of_script == "css": 48 | kwargs["css"] = { 49 | "all": frontend_static, 50 | } 51 | elif type_of_script == "js": 52 | kwargs["js"] = frontend_static 53 | 54 | return forms.Media(**kwargs).render() 55 | 56 | 57 | -------------------------------------------------------------------------------- /wagtail_editorjs/registry/element/utils.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Union, TYPE_CHECKING 2 | 3 | if TYPE_CHECKING: 4 | from .element import EditorJSElement 5 | 6 | from .attrs import ( 7 | EditorJSElementAttribute, 8 | EditorJSStyleAttribute, 9 | ) 10 | 11 | 12 | def make_attrs(attrs: dict[str, Any]) -> str: 13 | 14 | attrs = { 15 | key: _make_attr(value) 16 | for key, value in attrs.items() 17 | } 18 | 19 | return " ".join([f'{key}="{value}"' for key, value in attrs.items()]) 20 | 21 | 22 | def wrap_tag(tag_name, attrs, content = None, close_tag = True): 23 | attrs = attrs or {} 24 | attributes = f" {make_attrs(attrs)}" if attrs else "" 25 | if content is None and close_tag: 26 | return f"<{tag_name}{attributes}>" 27 | elif content is None and not close_tag: 28 | return f"<{tag_name}{attributes}>" 29 | return f"<{tag_name}{attributes}>{content}" 30 | 31 | 32 | def add_attributes(element: "EditorJSElement", **attrs: Union[str, list[str], dict[str, Any]]): 33 | """ 34 | Adds attributes to the element. 35 | """ 36 | for key, value in attrs.items(): 37 | if key.endswith("_"): 38 | key = key[:-1] 39 | 40 | if key in element.attrs: 41 | element.attrs[key].extend(value) 42 | else: 43 | element.attrs[key] = _make_attr(value) 44 | 45 | return element 46 | 47 | 48 | def _make_attr(value: Union[str, list[str], dict[str, Any]]): 49 | 50 | if isinstance(value, EditorJSElementAttribute): 51 | return value 52 | 53 | if isinstance(value, dict): 54 | return EditorJSStyleAttribute(value) 55 | 56 | return EditorJSElementAttribute(value) 57 | -------------------------------------------------------------------------------- /docs/examples/editorjs_tunes_example.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from django import forms 3 | from wagtail_editorjs.hooks import REGISTER_HOOK_NAME 4 | from wagtail_editorjs.registry import EditorJSFeatures, EditorJSTune, EditorJSElement 5 | from wagtail import hooks 6 | 7 | 8 | class AlignmentBlockTune(EditorJSTune): 9 | allowed_attributes = {"*": ["class"]} 10 | klass = "AlignmentBlockTune" 11 | js = ["https://cdn.jsdelivr.net/npm/editorjs-text-alignment-blocktune@latest"] 12 | 13 | def validate(self, data: Any): 14 | super().validate(data) 15 | alignment = data.get("alignment") 16 | if alignment not in ["left", "center", "right"]: 17 | raise forms.ValidationError("Invalid alignment value") 18 | 19 | def tune_element(self, element: EditorJSElement, tune_value: Any, context=None) -> EditorJSElement: 20 | element = super().tune_element(element, tune_value, context=context) 21 | element.add_attributes(class_=f"align-content-{tune_value['alignment'].strip()}") 22 | return element 23 | 24 | @hooks.register(REGISTER_HOOK_NAME) 25 | def register_editor_js_features(registry: EditorJSFeatures): 26 | registry.register( 27 | "text-alignment-tune", 28 | AlignmentBlockTune( 29 | "text-alignment-tune", 30 | inlineToolbar=True, 31 | config={ 32 | "default": "left", 33 | }, 34 | ), 35 | ) 36 | 37 | # To apply globally to all features 38 | registry.register_tune("text-alignment-tune") 39 | 40 | # Or optionally for a specific feature remove the wildcard above 41 | # and use the following (given the features "header" and "paragraph" are used in the editor) 42 | # registry.register_tune("text-alignment-tune", "header") 43 | # registry.register_tune("text-alignment-tune", "paragraph") 44 | -------------------------------------------------------------------------------- /wagtail_editorjs/static/wagtail_editorjs/vendor/editorjs/tools/delimiter.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Skipped minification because the original files appears to be already minified. 3 | * Original file: /npm/@editorjs/delimiter@1.4.0/dist/delimiter.umd.js 4 | * 5 | * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files 6 | */ 7 | (function(){"use strict";try{if(typeof document<"u"){var e=document.createElement("style");e.appendChild(document.createTextNode('.ce-delimiter{line-height:1.6em;width:100%;text-align:center}.ce-delimiter:before{display:inline-block;content:"***";font-size:30px;line-height:65px;height:30px;letter-spacing:.2em}')),document.head.appendChild(e)}}catch(t){console.error("vite-plugin-css-injected-by-js",t)}})(); 8 | (function(i,e){typeof exports=="object"&&typeof module<"u"?module.exports=e():typeof define=="function"&&define.amd?define(e):(i=typeof globalThis<"u"?globalThis:i||self,i.Delimiter=e())})(this,function(){"use strict";const i="",e='';/** 9 | * Delimiter Block for the Editor.js. 10 | * 11 | * @author CodeX (team@ifmo.su) 12 | * @copyright CodeX 2018 13 | * @license The MIT License (MIT) 14 | * @version 2.0.0 15 | */class r{static get isReadOnlySupported(){return!0}static get contentless(){return!0}constructor({data:t,config:o,api:n}){this.api=n,this._CSS={block:this.api.styles.block,wrapper:"ce-delimiter"},this._data={},this._element=this.drawView(),this.data=t}drawView(){let t=document.createElement("DIV");return t.classList.add(this._CSS.wrapper,this._CSS.block),t}render(){return this._element}save(t){return{}}static get toolbox(){return{icon:e,title:"Delimiter"}}}return r}); 16 | -------------------------------------------------------------------------------- /wagtail_editorjs/fields.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from django.db import models 3 | from django.utils.functional import cached_property 4 | from django.core.exceptions import ValidationError 5 | 6 | from .forms import EditorJSFormField 7 | from .registry import EDITOR_JS_FEATURES, get_features 8 | 9 | 10 | class EditorJSField(models.JSONField): 11 | def __init__(self, 12 | features: list[str] = None, 13 | tools_config: dict = None, 14 | *args, **kwargs 15 | ): 16 | self._features = features 17 | self.tools_config = tools_config or {} 18 | super().__init__(*args, **kwargs) 19 | 20 | @cached_property 21 | def features(self): 22 | return get_features(self._features) 23 | 24 | def deconstruct(self): 25 | name, path, args, kwargs = super().deconstruct() 26 | kwargs['features'] = self.features 27 | kwargs['tools_config'] = self.tools_config 28 | return name, path, args, kwargs 29 | 30 | def from_db_value(self, value: Any, expression, connection) -> Any: 31 | value = super().from_db_value( 32 | value, expression, connection 33 | ) 34 | return EDITOR_JS_FEATURES.to_python( 35 | self.features, value 36 | ) 37 | 38 | def get_prep_value(self, value: Any) -> Any: 39 | value = EDITOR_JS_FEATURES.prepare_value( 40 | self.features, value 41 | ) 42 | return super().get_prep_value(value) 43 | 44 | def to_python(self, value: Any) -> Any: 45 | value = super().to_python(value) 46 | return EDITOR_JS_FEATURES.to_python( 47 | self.features, value 48 | ) 49 | 50 | def formfield(self, **kwargs): 51 | return super().formfield(**{ 52 | 'form_class': EditorJSFormField, 53 | 'features': self.features, 54 | 'tools_config': self.tools_config, 55 | **kwargs 56 | }) 57 | -------------------------------------------------------------------------------- /wagtail_editorjs/blocks.py: -------------------------------------------------------------------------------- 1 | from django.utils.functional import cached_property 2 | from django.utils.translation import gettext_lazy as _ 3 | from wagtail.telepath import register 4 | from wagtail.blocks.field_block import ( 5 | FieldBlock, 6 | FieldBlockAdapter, 7 | ) 8 | 9 | from .render import render_editorjs_html 10 | from .forms import ( 11 | EditorJSFormField, 12 | get_features, 13 | ) 14 | 15 | 16 | class EditorJSBlock(FieldBlock): 17 | """ 18 | A Wagtail block which can be used to add the EditorJS 19 | widget into any streamfield or structblock. 20 | """ 21 | class Meta: 22 | icon = 'draft' 23 | label_format = _('EditorJS Block') 24 | 25 | def __init__(self, features: list[str] = None, tools_config: dict = None, **kwargs): 26 | self._features = features 27 | self.tools_config = tools_config or {} 28 | super().__init__(**kwargs) 29 | 30 | @cached_property 31 | def field(self): 32 | return EditorJSFormField( 33 | features=self.features, 34 | tools_config=self.tools_config, 35 | label=getattr(self.meta, 'label', None), 36 | required=getattr(self.meta, 'required', True), 37 | help_text=getattr(self.meta, 'help_text', ''), 38 | ) 39 | 40 | @property 41 | def features(self): 42 | return get_features(self._features) 43 | 44 | def render(self, value, context=None): 45 | """ 46 | Render the block value into HTML. 47 | This is so the block can be automatically included with `{% include_block my_editor_js_block %}`. 48 | """ 49 | return render_editorjs_html(self.features, value, context) 50 | 51 | 52 | from django.forms import Media 53 | 54 | 55 | class EditorJSBlockAdapter(FieldBlockAdapter): 56 | js_constructor = "wagtail_editorjs.blocks.EditorJSBlock" 57 | 58 | @cached_property 59 | def media(self): 60 | m = super().media 61 | return Media( 62 | js= m._js + [ 63 | "wagtail_editorjs/js/editorjs-block.js", 64 | ], 65 | css=m._css, 66 | ) 67 | 68 | register(EditorJSBlockAdapter(), EditorJSBlock) 69 | -------------------------------------------------------------------------------- /wagtail_editorjs/registry/element/attrs.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Union 2 | 3 | 4 | class EditorJSElementAttribute: 5 | def __init__(self, value: Union[str, list[str]], delimiter: str = " "): 6 | if not isinstance(value, (list, dict)): 7 | value = [value] 8 | 9 | if not isinstance(value, dict): 10 | value = set(value) 11 | 12 | self.value = value 13 | self.delimiter = delimiter 14 | 15 | def __eq__(self, other): 16 | if isinstance(other, EditorJSElementAttribute): 17 | return self.value == other.value 18 | 19 | if isinstance(other, (tuple, list, set)): 20 | for item in other: 21 | if item not in self.value: 22 | return False 23 | return True 24 | return False 25 | 26 | def extend(self, value: Any): 27 | 28 | if isinstance(value, (tuple, list, set)): 29 | self.value.update(value) 30 | 31 | else: 32 | self.value.add(value) 33 | 34 | def __str__(self): 35 | return self.delimiter.join([str(item) for item in self.value]) 36 | 37 | 38 | class EditorJSStyleAttribute(EditorJSElementAttribute): 39 | def __init__(self, value: dict): 40 | super().__init__(value, ";") 41 | 42 | def __repr__(self): 43 | return f"EditorJSStyleAttribute({self.value})" 44 | 45 | def __eq__(self, other): 46 | if isinstance(other, EditorJSStyleAttribute): 47 | return self.value == other.value 48 | 49 | if isinstance(other, dict): 50 | return self.value == other 51 | 52 | if isinstance(other, str): 53 | try: 54 | key, value = other.split(":") 55 | return self.value.get(key) == value.strip() 56 | except ValueError: 57 | return False 58 | return False 59 | 60 | def extend(self, value: dict = None, **kwargs): 61 | if value: 62 | if not isinstance(value, dict): 63 | raise ValueError("Value must be a dictionary") 64 | self.value.update(value) 65 | 66 | self.value.update(kwargs) 67 | 68 | def __str__(self): 69 | return self.delimiter.join([f'{key}: {value}' for key, value in self.value.items()]) 70 | -------------------------------------------------------------------------------- /wagtail_editorjs/test/core/tests/test_inlines.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | 4 | from .base import BaseEditorJSTest 5 | from wagtail_editorjs.registry import ( 6 | InlineEditorJSFeature, 7 | EDITOR_JS_FEATURES, 8 | ) 9 | 10 | import bs4 11 | 12 | 13 | 14 | TESTING_HTML = """ 15 |
16 |
17 |

Hello, World!

18 |
19 |
20 |

Header

21 |
22 |
23 | 24 |
25 |
26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 |
12
34
36 |
37 |
38 | """ 39 | 40 | 41 | class TestEditorJSInline(BaseEditorJSTest): 42 | def setUp(self): 43 | super().setUp() 44 | self.inlines = [ 45 | feature 46 | for feature in EDITOR_JS_FEATURES.features.values() 47 | if isinstance(feature, InlineEditorJSFeature) 48 | ] 49 | 50 | def test_inlines(self): 51 | 52 | for feature in self.inlines: 53 | feature: InlineEditorJSFeature 54 | test_data = feature.get_test_data() 55 | 56 | if not test_data: 57 | continue 58 | 59 | soup = bs4.BeautifulSoup(TESTING_HTML, "html.parser") 60 | testing_block = soup.find("div", {"data-testing-id": "TARGET"}) 61 | testing_block.clear() 62 | 63 | for i, (initial, _) in enumerate(test_data): 64 | initial_soup = bs4.BeautifulSoup(initial, "html.parser") 65 | initial_soup.attrs["data-testing-id"] = f"test_{i}" 66 | testing_block.append(initial_soup) 67 | 68 | feature.parse_inline_data(soup) 69 | 70 | html = str(soup) 71 | 72 | outputs = [i[1] for i in test_data] 73 | for i, output in enumerate(outputs): 74 | self.assertInHTML( 75 | output, 76 | html, 77 | ) 78 | 79 | -------------------------------------------------------------------------------- /wagtail_editorjs/registry/features/basic.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Union 2 | from django import forms 3 | 4 | from ..element import EditorJSElement 5 | from ..value import EditorJSBlock 6 | from .base import BaseEditorJSFeature 7 | 8 | 9 | class EditorJSJavascriptFeature(BaseEditorJSFeature): 10 | def __init__(self, tool_name: str, js: Union[str, list[str]] = None, css: Union[str, list[str]] = None, weight: int = 0, allowed_tags: list[str] = None, allowed_attributes: dict[str, list[str]] = None): 11 | # 1 for klass - unused for this type of feature. 12 | super().__init__(tool_name, 1, js, css, None, {}, weight=weight, allowed_tags=allowed_tags, allowed_attributes=allowed_attributes) 13 | 14 | def get_config(self, context: dict[str, Any] = None) -> dict: 15 | """ 16 | Javascript only features do not get access to any configuration. 17 | They are not passed into the EditorJS tools. 18 | """ 19 | return None 20 | 21 | 22 | class EditorJSFeature(BaseEditorJSFeature): 23 | 24 | def validate(self, data: Any): 25 | """ 26 | Perform basic validation for an EditorJS block feature. 27 | """ 28 | if not data: 29 | return 30 | 31 | if "data" not in data: 32 | raise forms.ValidationError("Invalid data format") 33 | 34 | def render_block_data(self, block: EditorJSBlock, context = None) -> "EditorJSElement": 35 | return EditorJSElement( 36 | "p", 37 | block["data"].get("text") 38 | ) 39 | 40 | @classmethod 41 | def get_test_data(cls): 42 | return [ 43 | { 44 | "text": "Hello, world!" 45 | } 46 | ] 47 | get_test_data.__doc__ = BaseEditorJSFeature.get_test_data.__doc__ 48 | 49 | 50 | class EditorJSTune(BaseEditorJSFeature): 51 | """ 52 | Works mostly like EditorJSFeature, but is used for tunes. 53 | Handles validation differently. 54 | """ 55 | 56 | def tune_element(self, element: "EditorJSElement", tune_value: Any, context = None) -> "EditorJSElement": 57 | """ 58 | Perform any action on the element based on the data provided by the tune. 59 | """ 60 | return element 61 | 62 | @classmethod 63 | def get_test_data(cls): 64 | """ 65 | Currently automatic tests for tunes are unsupported. 66 | """ 67 | return None 68 | -------------------------------------------------------------------------------- /wagtail_editorjs/static/wagtail_editorjs/vendor/editorjs/tools/inline-code.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Skipped minification because the original files appears to be already minified. 3 | * Original file: /npm/@editorjs/inline-code@1.5.0/dist/inline-code.umd.js 4 | * 5 | * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files 6 | */ 7 | (function(){"use strict";try{if(typeof document<"u"){var e=document.createElement("style");e.appendChild(document.createTextNode(".inline-code{background:rgba(250,239,240,.78);color:#b44437;padding:3px 4px;border-radius:5px;margin:0 1px;font-family:inherit;font-size:.86em;font-weight:500;letter-spacing:.3px}")),document.head.appendChild(e)}}catch(n){console.error("vite-plugin-css-injected-by-js",n)}})(); 8 | (function(i,s){typeof exports=="object"&&typeof module<"u"?module.exports=s():typeof define=="function"&&define.amd?define(s):(i=typeof globalThis<"u"?globalThis:i||self,i.InlineCode=s())})(this,function(){"use strict";const i="",s='';class n{static get CSS(){return"inline-code"}constructor({api:t}){this.api=t,this.button=null,this.tag="CODE",this.iconClasses={base:this.api.styles.inlineToolButton,active:this.api.styles.inlineToolButtonActive}}static get isInline(){return!0}render(){return this.button=document.createElement("button"),this.button.type="button",this.button.classList.add(this.iconClasses.base),this.button.innerHTML=this.toolboxIcon,this.button}surround(t){if(!t)return;let e=this.api.selection.findParentTag(this.tag,n.CSS);e?this.unwrap(e):this.wrap(t)}wrap(t){let e=document.createElement(this.tag);e.classList.add(n.CSS),e.appendChild(t.extractContents()),t.insertNode(e),this.api.selection.expandToTag(e)}unwrap(t){this.api.selection.expandToTag(t);let e=window.getSelection(),o=e.getRangeAt(0),a=o.extractContents();t.parentNode.removeChild(t),o.insertNode(a),e.removeAllRanges(),e.addRange(o)}checkState(){const t=this.api.selection.findParentTag(this.tag,n.CSS);this.button.classList.toggle(this.iconClasses.active,!!t)}get toolboxIcon(){return s}static get sanitize(){return{code:{class:n.CSS}}}}return n}); 9 | -------------------------------------------------------------------------------- /wagtail_editorjs/test/core/tests/base.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from wagtail.models import Collection, Page 3 | from wagtail.images.tests.utils import ( 4 | get_test_image_file, 5 | get_test_image_file_jpeg, 6 | get_test_image_file_webp, 7 | get_test_image_file_avif, 8 | ) 9 | from wagtail.images import get_image_model 10 | from wagtail.documents import get_document_model 11 | from wagtail_editorjs import settings 12 | 13 | 14 | Image = get_image_model() 15 | Document = get_document_model() 16 | 17 | 18 | 19 | class BaseEditorJSTest(TestCase): 20 | """ 21 | Setup test data for EditorJS tests 22 | This is so blocks can freely be tested and query the test db 23 | without having to worry about setting up the data. 24 | """ 25 | def setUp(self) -> None: 26 | image_funcs = [ 27 | get_test_image_file, 28 | get_test_image_file_jpeg, 29 | get_test_image_file_webp, 30 | get_test_image_file_avif, 31 | ] 32 | self.collection = Collection.get_first_root_node() 33 | root_page = Page.objects.filter(depth=2).first() 34 | 35 | child_url_paths = [] 36 | sibling_url_paths = [] 37 | 38 | for i in range(100): 39 | 40 | child_url_paths.append(f"test-page-{i}") 41 | sibling_url_paths.append(f"test-subchild-page-{i}") 42 | 43 | child = root_page.add_child(instance=Page( 44 | title=f"Test Page {i}", 45 | slug=f"test-page-{i}", 46 | path=f"/test-page-{i}/", 47 | url_path=f"/test-page-{i}/", 48 | )) 49 | child.set_url_path(root_page) 50 | child.save() 51 | 52 | subchild = child.add_child(instance=Page( 53 | title=f"Test Page subchild {i}", 54 | slug=f"test-subchild-page-{i}", 55 | path=f"/test-subchild-page-{i}/", 56 | url_path=f"/test-subchild-page-{i}/", 57 | )) 58 | subchild.set_url_path(child) 59 | subchild.save() 60 | 61 | Document.objects.create(file=get_test_image_file(), title=f"Test Document {i}", collection=self.collection) 62 | Image.objects.create( 63 | file=image_funcs[i % len(image_funcs)](), 64 | title=f"Test Image {i}", 65 | collection=self.collection 66 | ) 67 | # call_command("fixtree") 68 | # call_command("set_url_paths") 69 | 70 | -------------------------------------------------------------------------------- /wagtail_editorjs/static/wagtail_editorjs/vendor/editorjs/tools/marker.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Skipped minification because the original files appears to be already minified. 3 | * Original file: /npm/@editorjs/marker@1.4.0/dist/marker.umd.js 4 | * 5 | * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files 6 | */ 7 | (function(){"use strict";try{if(typeof document<"u"){var e=document.createElement("style");e.appendChild(document.createTextNode(".cdx-marker{background:rgba(245,235,111,.29);padding:3px 0}")),document.head.appendChild(e)}}catch(d){console.error("vite-plugin-css-injected-by-js",d)}})(); 8 | (function(i,s){typeof exports=="object"&&typeof module<"u"?module.exports=s():typeof define=="function"&&define.amd?define(s):(i=typeof globalThis<"u"?globalThis:i||self,i.Marker=s())})(this,function(){"use strict";const i="",s='';class n{static get CSS(){return"cdx-marker"}constructor({api:t}){this.api=t,this.button=null,this.tag="MARK",this.iconClasses={base:this.api.styles.inlineToolButton,active:this.api.styles.inlineToolButtonActive}}static get isInline(){return!0}render(){return this.button=document.createElement("button"),this.button.type="button",this.button.classList.add(this.iconClasses.base),this.button.innerHTML=this.toolboxIcon,this.button}surround(t){if(!t)return;let e=this.api.selection.findParentTag(this.tag,n.CSS);e?this.unwrap(e):this.wrap(t)}wrap(t){let e=document.createElement(this.tag);e.classList.add(n.CSS),e.appendChild(t.extractContents()),t.insertNode(e),this.api.selection.expandToTag(e)}unwrap(t){this.api.selection.expandToTag(t);let e=window.getSelection(),o=e.getRangeAt(0),a=o.extractContents();t.parentNode.removeChild(t),o.insertNode(a),e.removeAllRanges(),e.addRange(o)}checkState(){const t=this.api.selection.findParentTag(this.tag,n.CSS);this.button.classList.toggle(this.iconClasses.active,!!t)}get toolboxIcon(){return s}static get sanitize(){return{mark:{class:n.CSS}}}}return n}); 9 | -------------------------------------------------------------------------------- /wagtail_editorjs/django_editor.py: -------------------------------------------------------------------------------- 1 | from django.utils.functional import cached_property 2 | from django.utils.safestring import mark_safe 3 | from .forms import EditorJSFormField, EditorJSWidget 4 | 5 | 6 | 7 | class DjangoEditorJSFormField(EditorJSFormField): 8 | @cached_property 9 | def widget(self): 10 | return EditorJSDjangoWidget( 11 | features=self.features, 12 | tools_config=self.tools_config, 13 | ) 14 | 15 | 16 | 17 | class EditorJSDjangoWidget(EditorJSWidget): 18 | """ 19 | Taken from wagtail. 20 | This class is deprecated in wagtail 7.0 21 | This might still be useful if we have wagtail installed 22 | but would like to use the editorjs widget in a non-wagtailadmin form. 23 | """ 24 | def render_html(self, name, value, attrs): 25 | """Render the HTML (non-JS) portion of the field markup""" 26 | return super().render(name, value, attrs) 27 | 28 | def render(self, name, value, attrs=None, renderer=None): 29 | # no point trying to come up with sensible semantics for when 'id' is missing from attrs, 30 | # so let's make sure it fails early in the process 31 | try: 32 | id_ = attrs["id"] 33 | except (KeyError, TypeError): 34 | raise TypeError( 35 | "WidgetWithScript cannot be rendered without an 'id' attribute" 36 | ) 37 | 38 | value_data = self.get_value_data(value) 39 | widget_html = self.render_html(name, value_data, attrs) 40 | 41 | js = self.render_js_init(id_, name, value_data) 42 | out = f"{widget_html}" 43 | return mark_safe(out) 44 | 45 | def render_js_init(self, id_, name, value): 46 | # Adapted from editorjs-widget-controller.js 47 | return """ 48 | let editorJSWidget__wrapper = document.querySelector(`#${id}-wagtail-editorjs-widget-wrapper`); 49 | let editorJSWidget__configElem = editorJSWidget__wrapper.querySelector(`#wagtail-editorjs-config`); 50 | let editorJSWidget__config = JSON.parse(editorJSWidget__configElem.textContent); 51 | let editorJSWidget__keys = Object.keys(editorJSWidget__config.tools); 52 | for (let i = 0; i < editorJSWidget__keys.length; i++) { 53 | const key = editorJSWidget__keys[i]; 54 | const toolConfig = editorJSWidget__config.tools[key]; 55 | const toolClass = window[toolConfig.class]; 56 | toolConfig.class = toolClass; 57 | editorJSWidget__config.tools[key] = toolConfig; 58 | } 59 | let editorJSWidget__element = document.querySelector(`#${id}`); 60 | new window.EditorJSWidget( 61 | editorJSWidget__wrapper, 62 | editorJSWidget__element, 63 | editorJSWidget__config, 64 | ); 65 | """ % { 66 | "id": id_, 67 | } 68 | 69 | -------------------------------------------------------------------------------- /docs/examples/editorjs_feature_example.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | from typing import Any 4 | from django import forms 5 | from wagtail_editorjs.hooks import REGISTER_HOOK_NAME 6 | from wagtail_editorjs.registry import ( 7 | EditorJSFeature, EditorJSFeatures, 8 | EditorJSElement, EditorJSBlock, 9 | ) 10 | from wagtail import hooks 11 | 12 | 13 | class CustomImageFeature(EditorJSFeature): 14 | # These tags are allowed and will not be cleaned by bleach if enabled. 15 | allowed_tags = ["img"] 16 | allowed_attributes = ["src", "alt", "style"] 17 | 18 | # Provide extra configuration for the feature. 19 | def get_config(self, context: dict[str, Any]): 20 | # This context is always present. 21 | # It is the widget context - NOT the request context. 22 | config = super().get_config() or {} 23 | config["config"] = {} # my custom configuration 24 | return config 25 | 26 | def validate(self, data: Any): 27 | super().validate(data) 28 | 29 | if 'url' not in data['data']: 30 | raise forms.ValidationError('Invalid data.url value') 31 | 32 | if "caption" not in data["data"]: 33 | raise forms.ValidationError('Invalid data.caption value') 34 | 35 | # ... 36 | 37 | def render_block_data(self, block: EditorJSBlock, context = None) -> EditorJSElement: 38 | # Context is not guaranteed to be present. This is the request context. 39 | return EditorJSElement( 40 | "img", 41 | close_tag=False, 42 | attrs={ 43 | "src": block["data"]["url"], 44 | "alt": block["data"]["caption"], 45 | "style": { 46 | "border": "1px solid black" if block["data"]["withBorder"] else "none", 47 | "background-color": "lightgray" if block["data"]["withBackground"] else "none", 48 | "width": "100%" if block["data"]["stretched"] else "auto", 49 | } 50 | }, 51 | ) 52 | 53 | 54 | @hooks.register(REGISTER_HOOK_NAME) 55 | def register_editorjs_features(features: EditorJSFeatures): 56 | # The feature name as you'd like to use in your field/block. 57 | feature_name = "simple-image" 58 | 59 | # The classname as defined in javascript. 60 | # This is accessed with `window.[feature_js_class]`. 61 | # In this case; `window.SimpleImage`. 62 | feature_js_class = "SimpleImage" 63 | 64 | # Register the feature with the editor. 65 | features.register( 66 | feature_name, 67 | CustomImageFeature( 68 | feature_name, 69 | feature_js_class, 70 | js = [ 71 | # Import from CDN 72 | "https://cdn.jsdelivr.net/npm/@editorjs/simple-image", 73 | ], 74 | ), 75 | ) 76 | -------------------------------------------------------------------------------- /wagtail_editorjs/static/wagtail_editorjs/js/tools/tooltips/frontend.js: -------------------------------------------------------------------------------- 1 | class TippyTooltip { 2 | /** 3 | * @param {HTMLElement} element 4 | **/ 5 | constructor(element) { 6 | this.element = element; 7 | this.tooltipConfig = this.makeConfig(); 8 | this.init(); 9 | } 10 | 11 | init() { 12 | if (!window.tippy) { 13 | console.debug("Tippy tooltips disabled"); 14 | return; 15 | } 16 | this.tippy = tippy(this.element, this.tooltipConfig); 17 | } 18 | 19 | makeConfig() { 20 | const cfg = {} 21 | for (const attr of this.element.attributes) { 22 | if (attr.name.startsWith("data-tippy-")) { 23 | const key = attr 24 | .name.replace("data-tippy-", "") 25 | .replace(/-([a-z])/g, function (g) { 26 | return g[1].toUpperCase(); 27 | } 28 | );; 29 | let value = attr.value; 30 | let valueLower = value.toLowerCase(); 31 | if (valueLower === "true" || valueLower === "false") { 32 | value = valueLower === "true"; 33 | } else if (!isNaN(value)) { 34 | value = parseFloat(value); 35 | } else if (value === "null" || valueLower === "none") { 36 | value = null; 37 | } 38 | cfg[key] = value; 39 | } 40 | } 41 | this.tooltipConfig = cfg; 42 | return cfg; 43 | } 44 | } 45 | 46 | (function() { 47 | const documentReadyFn = () => { 48 | const initTippyNode = (node) => { 49 | if (node.classList && node.classList.contains("wagtail-tooltip") && !node._tippy) { 50 | node._tippy = new TippyTooltip(node); 51 | } 52 | 53 | const tooltipNodes = node.querySelectorAll(".wagtail-tooltip"); 54 | if (tooltipNodes.length) { 55 | tooltipNodes.forEach(initTippyNode); 56 | } 57 | }; 58 | 59 | const observerFunc = (mutationsList, observer) => { 60 | for (const mutation of mutationsList) { 61 | if (mutation.type === "childList") { 62 | for (const node of mutation.addedNodes) { 63 | initTippyNode(node); 64 | } 65 | } 66 | } 67 | }; 68 | 69 | const observer = new MutationObserver(observerFunc); 70 | observer.observe(document.body, { childList: true, subtree: true }); 71 | 72 | const tooltipNodes = document.querySelectorAll(".wagtail-tooltip"); 73 | tooltipNodes.forEach(initTippyNode); 74 | } 75 | 76 | if (document.readyState === "complete") { 77 | documentReadyFn(); 78 | } else { 79 | document.addEventListener("DOMContentLoaded", documentReadyFn); 80 | } 81 | })(); -------------------------------------------------------------------------------- /wagtail_editorjs/test/core/tests/test_attrs.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from wagtail_editorjs.registry.element.element import EditorJSElement 3 | from wagtail_editorjs.registry.element.utils import ( 4 | wrap_tag, 5 | _make_attr, 6 | add_attributes, 7 | EditorJSElementAttribute, 8 | EditorJSStyleAttribute, 9 | ) 10 | 11 | 12 | class TestEditorJSElement(TestCase): 13 | 14 | def test_element(self): 15 | element = EditorJSElement( 16 | "div", 17 | "Hello, World!", 18 | attrs = { 19 | "class": "test-class", 20 | "id": "test-id", 21 | } 22 | ) 23 | 24 | self.assertHTMLEqual( 25 | str(element), 26 | '
Hello, World!
' 27 | ) 28 | 29 | def test_element_attrs(self): 30 | element = EditorJSElement( 31 | "div", 32 | "Hello, World!", 33 | ) 34 | 35 | element = add_attributes(element, **{ 36 | "class_": "test-class", 37 | "data-test": "test-data", 38 | }) 39 | 40 | self.assertHTMLEqual( 41 | str(element), 42 | '
Hello, World!
' 43 | ) 44 | 45 | def test_make_attr(self): 46 | 47 | attrs = _make_attr({ 48 | "color": "red", 49 | "background-color": "blue", 50 | }) 51 | 52 | self.assertIsInstance(attrs, EditorJSStyleAttribute) 53 | self.assertEqual( 54 | str(attrs), 55 | 'color: red;background-color: blue' 56 | ) 57 | 58 | attrs = _make_attr("test-class") 59 | self.assertIsInstance(attrs, EditorJSElementAttribute) 60 | self.assertEqual( 61 | str(attrs), 62 | 'test-class' 63 | ) 64 | 65 | def test_wrap_tag(self): 66 | tag = wrap_tag( 67 | "div", 68 | { 69 | "class": "test-class", 70 | "id": "test-id", 71 | }, 72 | "Hello, World!" 73 | ) 74 | 75 | self.assertHTMLEqual( 76 | tag, 77 | '
Hello, World!
' 78 | ) 79 | 80 | def test_wrap_tag_styles(self): 81 | tag = wrap_tag( 82 | "div", 83 | { 84 | "id": "test-id", 85 | "class": ["test-class", "test-class-2"], 86 | "style": { 87 | "color": "red", 88 | "background-color": "blue", 89 | } 90 | }, 91 | "Hello, World!" 92 | ) 93 | 94 | self.assertHTMLEqual( 95 | tag, 96 | '
Hello, World!
' 97 | ) 98 | 99 | 100 | -------------------------------------------------------------------------------- /wagtail_editorjs/static/wagtail_editorjs/vendor/editorjs/tools/paragraph.umd.js: -------------------------------------------------------------------------------- 1 | (function(){"use strict";try{if(typeof document<"u"){var e=document.createElement("style");e.appendChild(document.createTextNode(".ce-paragraph{line-height:1.6em;outline:none}.ce-paragraph[data-placeholder]:empty:before{content:attr(data-placeholder);color:#707684;font-weight:400;opacity:0}.codex-editor--empty .ce-block:first-child .ce-paragraph[data-placeholder]:empty:before{opacity:1}.codex-editor--toolbox-opened .ce-block:first-child .ce-paragraph[data-placeholder]:empty:before,.codex-editor--empty .ce-block:first-child .ce-paragraph[data-placeholder]:empty:focus:before{opacity:0}.ce-paragraph p:first-of-type{margin-top:0}.ce-paragraph p:last-of-type{margin-bottom:0}")),document.head.appendChild(e)}}catch(t){console.error("vite-plugin-css-injected-by-js",t)}})(); 2 | (function(n,i){typeof exports=="object"&&typeof module<"u"?module.exports=i():typeof define=="function"&&define.amd?define(i):(n=typeof globalThis<"u"?globalThis:n||self,n.Paragraph=i())})(this,function(){"use strict";const n="",i='';/** 3 | * Base Paragraph Block for the Editor.js. 4 | * Represents a regular text block 5 | * 6 | * @author CodeX (team@codex.so) 7 | * @copyright CodeX 2018 8 | * @license The MIT License (MIT) 9 | */class s{static get DEFAULT_PLACEHOLDER(){return""}constructor({data:t,config:e,api:a,readOnly:r}){this.api=a,this.readOnly=r,this._CSS={block:this.api.styles.block,wrapper:"ce-paragraph"},this.readOnly||(this.onKeyUp=this.onKeyUp.bind(this)),this._placeholder=e.placeholder?e.placeholder:s.DEFAULT_PLACEHOLDER,this._data={},this._element=null,this._preserveBlank=e.preserveBlank!==void 0?e.preserveBlank:!1,this.data=t}onKeyUp(t){if(t.code!=="Backspace"&&t.code!=="Delete")return;const{textContent:e}=this._element;e===""&&(this._element.innerHTML="")}drawView(){const t=document.createElement("DIV");return t.classList.add(this._CSS.wrapper,this._CSS.block),t.contentEditable=!1,t.dataset.placeholder=this.api.i18n.t(this._placeholder),this._data.text&&(t.innerHTML=this._data.text),this.readOnly||(t.contentEditable=!0,t.addEventListener("keyup",this.onKeyUp)),t}render(){return this._element=this.drawView(),this._element}merge(t){const e={text:this.data.text+t.text};this.data=e}validate(t){return!(t.text.trim()===""&&!this._preserveBlank)}save(t){return{text:t.innerHTML}}onPaste(t){const e={text:t.detail.data.innerHTML};this.data=e}static get conversionConfig(){return{export:"text",import:"text"}}static get sanitize(){return{text:{br:!0}}}static get isReadOnlySupported(){return!0}get data(){if(this._element!==null){const t=this._element.innerHTML;this._data.text=t}return this._data}set data(t){this._data=t||{},this._element!==null&&this.hydrate()}hydrate(){window.requestAnimationFrame(()=>{this._element.innerHTML=this._data.text||""})}static get pasteConfig(){return{tags:["P"]}}static get toolbox(){return{icon:i,title:"Text"}}}return s}); 10 | -------------------------------------------------------------------------------- /wagtail_editorjs/static/wagtail_editorjs/vendor/editorjs/tools/raw.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Skipped minification because the original files appears to be already minified. 3 | * Original file: /npm/@editorjs/raw@2.5.0/dist/raw.umd.js 4 | * 5 | * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files 6 | */ 7 | (function(){"use strict";try{if(typeof document<"u"){var e=document.createElement("style");e.appendChild(document.createTextNode(".ce-rawtool__textarea{min-height:200px;resize:vertical;border-radius:8px;border:0;background-color:#1e2128;font-family:Menlo,Monaco,Consolas,Courier New,monospace;font-size:12px;line-height:1.6;letter-spacing:-.2px;color:#a1a7b6;overscroll-behavior:contain}")),document.head.appendChild(e)}}catch(o){console.error("vite-plugin-css-injected-by-js",o)}})(); 8 | (function(s,t){typeof exports=="object"&&typeof module<"u"?module.exports=t():typeof define=="function"&&define.amd?define(t):(s=typeof globalThis<"u"?globalThis:s||self,s.RawTool=t())})(this,function(){"use strict";const s="",t='';/** 9 | * Raw HTML Tool for CodeX Editor 10 | * 11 | * @author CodeX (team@codex.so) 12 | * @copyright CodeX 2018 13 | * @license The MIT License (MIT) 14 | */class i{static get isReadOnlySupported(){return!0}static get displayInToolbox(){return!0}static get enableLineBreaks(){return!0}static get toolbox(){return{icon:t,title:"Raw HTML"}}constructor({data:e,config:r,api:a,readOnly:n}){this.api=a,this.readOnly=n,this.placeholder=r.placeholder||i.DEFAULT_PLACEHOLDER,this.CSS={baseClass:this.api.styles.block,input:this.api.styles.input,wrapper:"ce-rawtool",textarea:"ce-rawtool__textarea"},this.data={html:e.html||""},this.textarea=null,this.resizeDebounce=null}render(){const e=document.createElement("div"),r=100;return this.textarea=document.createElement("textarea"),e.classList.add(this.CSS.baseClass,this.CSS.wrapper),this.textarea.classList.add(this.CSS.textarea,this.CSS.input),this.textarea.textContent=this.data.html,this.textarea.placeholder=this.placeholder,this.readOnly?this.textarea.disabled=!0:this.textarea.addEventListener("input",()=>{this.onInput()}),e.appendChild(this.textarea),setTimeout(()=>{this.resize()},r),e}save(e){return{html:e.querySelector("textarea").value}}static get DEFAULT_PLACEHOLDER(){return"Enter HTML code"}static get sanitize(){return{html:!0}}onInput(){this.resizeDebounce&&clearTimeout(this.resizeDebounce),this.resizeDebounce=setTimeout(()=>{this.resize()},200)}resize(){this.textarea.style.height="auto",this.textarea.style.height=this.textarea.scrollHeight+"px"}}return i}); 15 | -------------------------------------------------------------------------------- /wagtail_editorjs/static/wagtail_editorjs/vendor/editorjs/tools/code.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Skipped minification because the original files appears to be already minified. 3 | * Original file: /npm/@editorjs/code@2.9.0/dist/code.umd.js 4 | * 5 | * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files 6 | */ 7 | (function(){"use strict";try{if(typeof document<"u"){var e=document.createElement("style");e.appendChild(document.createTextNode(".ce-code__textarea{min-height:200px;font-family:Menlo,Monaco,Consolas,Courier New,monospace;color:#41314e;line-height:1.6em;font-size:12px;background:#f8f7fa;border:1px solid #f1f1f4;box-shadow:none;white-space:pre;word-wrap:normal;overflow-x:auto;resize:vertical}")),document.head.appendChild(e)}}catch(o){console.error("vite-plugin-css-injected-by-js",o)}})(); 8 | (function(o,i){typeof exports=="object"&&typeof module<"u"?module.exports=i():typeof define=="function"&&define.amd?define(i):(o=typeof globalThis<"u"?globalThis:o||self,o.CodeTool=i())})(this,function(){"use strict";const o="";function i(u,t){let s="";for(;s!==` 9 | `&&t>0;)t=t-1,s=u.substr(t,1);return s===` 10 | `&&(t+=1),t}const h='';/** 11 | * CodeTool for Editor.js 12 | * 13 | * @author CodeX (team@ifmo.su) 14 | * @copyright CodeX 2018 15 | * @license MIT 16 | * @version 2.0.0 17 | */class c{static get isReadOnlySupported(){return!0}static get enableLineBreaks(){return!0}constructor({data:t,config:e,api:s,readOnly:n}){this.api=s,this.readOnly=n,this.placeholder=this.api.i18n.t(e.placeholder||c.DEFAULT_PLACEHOLDER),this.CSS={baseClass:this.api.styles.block,input:this.api.styles.input,wrapper:"ce-code",textarea:"ce-code__textarea"},this.nodes={holder:null,textarea:null},this.data={code:t.code||""},this.nodes.holder=this.drawView()}drawView(){const t=document.createElement("div"),e=document.createElement("textarea");return t.classList.add(this.CSS.baseClass,this.CSS.wrapper),e.classList.add(this.CSS.textarea,this.CSS.input),e.textContent=this.data.code,e.placeholder=this.placeholder,this.readOnly&&(e.disabled=!0),t.appendChild(e),e.addEventListener("keydown",s=>{switch(s.code){case"Tab":this.tabHandler(s);break}}),this.nodes.textarea=e,t}render(){return this.nodes.holder}save(t){return{code:t.querySelector("textarea").value}}onPaste(t){const e=t.detail.data;this.data={code:e.textContent}}get data(){return this._data}set data(t){this._data=t,this.nodes.textarea&&(this.nodes.textarea.textContent=t.code)}static get toolbox(){return{icon:h,title:"Code"}}static get DEFAULT_PLACEHOLDER(){return"Enter a code"}static get pasteConfig(){return{tags:["pre"]}}static get sanitize(){return{code:!0}}tabHandler(t){t.stopPropagation(),t.preventDefault();const e=t.target,s=t.shiftKey,n=e.selectionStart,r=e.value,a=" ";let d;if(!s)d=n+a.length,e.value=r.substring(0,n)+a+r.substring(n);else{const l=i(r,n);if(r.substr(l,a.length)!==a)return;e.value=r.substring(0,l)+r.substring(l+a.length),d=n-a.length}e.setSelectionRange(d,d)}}return c}); 18 | -------------------------------------------------------------------------------- /wagtail_editorjs/static/wagtail_editorjs/js/tools/wagtail-document.js: -------------------------------------------------------------------------------- 1 | const wagtailFileIcon = ` 2 | 3 | 4 | 5 | 6 | `; 7 | 8 | 9 | 10 | class WagtailDocumentTool extends window.BaseWagtailChooserTool { 11 | get iconHTML() { 12 | return wagtailFileIcon; 13 | } 14 | 15 | static get chooserType() { 16 | return 'document'; 17 | } 18 | 19 | newChooser() { 20 | return new window.DocumentChooser(this.config.chooserId) 21 | } 22 | 23 | showActions(wrapperTag) { 24 | this.URLInput.value = wrapperTag.href; 25 | 26 | let chooseNewFunc = null; 27 | chooseNewFunc = (e) => { 28 | const data = this.chooser.state; 29 | this.setDataOnWrapper(wrapperTag, data); 30 | this.URLInput.value = data.url; 31 | this.chooser.input.removeEventListener('change', chooseNewFunc); 32 | }; 33 | 34 | this.chooseNewButton.onclick = (() => { 35 | this.chooser.openChooserModal(); 36 | this.chooser.input.addEventListener('change', chooseNewFunc); 37 | }); 38 | 39 | this.api.tooltip.onHover(this.chooseNewButton, this.api.i18n.t('Choose new ' + this.constructor["chooserType"]), { 40 | placement: 'top', 41 | hidingDelay: 200, 42 | }); 43 | 44 | 45 | this.container.hidden = false; 46 | 47 | 48 | } 49 | 50 | hideActions() { 51 | this.container.hidden = true; 52 | this.URLInput.value = ''; 53 | this.URLInput.onchange = null; 54 | this.chooseNewButton.onclick = null; 55 | this.chooseNewButton.classList.remove( 56 | this.api.styles.inlineToolButtonActive 57 | ); 58 | } 59 | 60 | renderActions() { 61 | this.container = document.createElement('div'); 62 | this.container.classList.add("wagtail-link-tool-actions", "column"); 63 | this.container.hidden = true; 64 | 65 | const btnContainer = document.createElement('div'); 66 | btnContainer.classList.add("wagtail-link-tool-actions"); 67 | 68 | this.chooseNewButton = document.createElement('button'); 69 | this.chooseNewButton.type = 'button'; 70 | this.chooseNewButton.innerHTML = wagtailFileIcon; 71 | this.chooseNewButton.dataset.chooserActionChoose = 'true'; 72 | this.chooseNewButton.classList.add( 73 | this.api.styles.inlineToolButton, 74 | ) 75 | 76 | this.URLInput = document.createElement('input'); 77 | this.URLInput.type = 'text'; 78 | this.URLInput.disabled = true; 79 | this.URLInput.placeholder = this.api.i18n.t('URL'); 80 | this.URLInput.classList.add( 81 | this.api.styles.input, 82 | this.api.styles.inputUrl, 83 | ); 84 | 85 | btnContainer.appendChild(this.URLInput); 86 | btnContainer.appendChild(this.chooseNewButton); 87 | 88 | this.container.appendChild(btnContainer); 89 | 90 | return this.container; 91 | } 92 | } 93 | 94 | window.WagtailDocumentTool = WagtailDocumentTool; -------------------------------------------------------------------------------- /wagtail_editorjs/static/wagtail_editorjs/vendor/editorjs/tools/warning.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Skipped minification because the original files appears to be already minified. 3 | * Original file: /npm/@editorjs/warning@1.4.0/dist/warning.umd.js 4 | * 5 | * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files 6 | */ 7 | (function(){"use strict";try{if(typeof document<"u"){var e=document.createElement("style");e.appendChild(document.createTextNode(`.cdx-warning{position:relative}@media all and (min-width: 736px){.cdx-warning{padding-left:36px}}.cdx-warning [contentEditable=true][data-placeholder]:before{position:absolute;content:attr(data-placeholder);color:#707684;font-weight:400;opacity:0}.cdx-warning [contentEditable=true][data-placeholder]:empty:before{opacity:1}.cdx-warning [contentEditable=true][data-placeholder]:empty:focus:before{opacity:0}.cdx-warning:before{content:"";background-image:url("data:image/svg+xml,%3Csvg width='24' height='24' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='5' y='5' width='14' height='14' rx='4' stroke='black' stroke-width='2'/%3E%3Cline x1='12' y1='9' x2='12' y2='12' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3Cpath d='M12 15.02V15.01' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E");width:24px;height:24px;background-size:24px 24px;position:absolute;margin-top:8px;left:0}@media all and (max-width: 735px){.cdx-warning:before{display:none}}.cdx-warning__message{min-height:85px}.cdx-warning__title{margin-bottom:6px}`)),document.head.appendChild(e)}}catch(t){console.error("vite-plugin-css-injected-by-js",t)}})(); 8 | (function(r,n){typeof exports=="object"&&typeof module<"u"?module.exports=n():typeof define=="function"&&define.amd?define(n):(r=typeof globalThis<"u"?globalThis:r||self,r.Warning=n())})(this,function(){"use strict";const r='',n="";class a{static get isReadOnlySupported(){return!0}static get toolbox(){return{icon:r,title:"Warning"}}static get enableLineBreaks(){return!0}static get DEFAULT_TITLE_PLACEHOLDER(){return"Title"}static get DEFAULT_MESSAGE_PLACEHOLDER(){return"Message"}get CSS(){return{baseClass:this.api.styles.block,wrapper:"cdx-warning",title:"cdx-warning__title",input:this.api.styles.input,message:"cdx-warning__message"}}constructor({data:t,config:e,api:s,readOnly:i}){this.api=s,this.readOnly=i,this.titlePlaceholder=e.titlePlaceholder||a.DEFAULT_TITLE_PLACEHOLDER,this.messagePlaceholder=e.messagePlaceholder||a.DEFAULT_MESSAGE_PLACEHOLDER,this.data={title:t.title||"",message:t.message||""}}render(){const t=this._make("div",[this.CSS.baseClass,this.CSS.wrapper]),e=this._make("div",[this.CSS.input,this.CSS.title],{contentEditable:!this.readOnly,innerHTML:this.data.title}),s=this._make("div",[this.CSS.input,this.CSS.message],{contentEditable:!this.readOnly,innerHTML:this.data.message});return e.dataset.placeholder=this.titlePlaceholder,s.dataset.placeholder=this.messagePlaceholder,t.appendChild(e),t.appendChild(s),t}save(t){const e=t.querySelector(`.${this.CSS.title}`),s=t.querySelector(`.${this.CSS.message}`);return Object.assign(this.data,{title:e.innerHTML,message:s.innerHTML})}_make(t,e=null,s={}){const i=document.createElement(t);Array.isArray(e)?i.classList.add(...e):e&&i.classList.add(e);for(const l in s)i[l]=s[l];return i}static get sanitize(){return{title:{},message:{}}}}return a}); 9 | -------------------------------------------------------------------------------- /wagtail_editorjs/registry/value.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Union, TYPE_CHECKING 2 | import datetime 3 | 4 | if TYPE_CHECKING: 5 | from .features import EditorJSFeature 6 | 7 | 8 | class EditorJSBlock(dict): 9 | def __init__(self, data: dict, features: list[str]): 10 | self.features = features 11 | super().__init__(data) 12 | 13 | @property 14 | def id(self): 15 | return self.get("id") 16 | 17 | @property 18 | def type(self): 19 | return self.get("type") 20 | 21 | @property 22 | def data(self): 23 | return self.get("data", {}) 24 | 25 | @property 26 | def tunes(self): 27 | return self.get("tunes", {}) 28 | 29 | 30 | class EditorJSValue(dict): 31 | def __init__(self, data: dict, features: dict[str, "EditorJSFeature"]): 32 | self._features = features 33 | super().__init__(data) 34 | 35 | @property 36 | def blocks(self) -> list["EditorJSBlock"]: 37 | return self["blocks"] 38 | 39 | @property 40 | def time(self): 41 | time = self.get("time") 42 | if time is None: 43 | return None 44 | return datetime.datetime.fromtimestamp(time) 45 | 46 | @property 47 | def version(self): 48 | return self.get("version") 49 | 50 | @property 51 | def features(self) -> list["EditorJSFeature"]: 52 | return self._features 53 | 54 | def __getitem__(self, __key: Any) -> Any: 55 | if isinstance(__key, int): 56 | return self.blocks[__key] 57 | 58 | return super().__getitem__(__key) 59 | 60 | def get_blocks_by_name(self, name: str) -> list["EditorJSBlock"]: 61 | if name not in self._features: 62 | return [] 63 | 64 | return [ 65 | block 66 | for block in self.blocks 67 | if block.get('type') == name 68 | ] 69 | 70 | def get_block_by_id(self, id: str) -> "EditorJSBlock": 71 | for block in self.blocks: 72 | if block.get("id") == id: 73 | return block 74 | return None 75 | 76 | def get_range(self, start: int, end: int) -> list["EditorJSBlock"]: 77 | return self["blocks"][start:end] 78 | 79 | def set_range(self, start: int, end: int, blocks: list["EditorJSBlock"]): 80 | b = self["blocks"] 81 | if start < 0: 82 | start = 0 83 | 84 | if end > len(b): 85 | end = len(b) 86 | 87 | if start > end: 88 | start = end 89 | 90 | if start == end: 91 | return 92 | 93 | b[start:end] = list( 94 | map(self._verify_block, blocks), 95 | ) 96 | self["blocks"] = b 97 | 98 | def insert(self, index: int, block: "EditorJSBlock"): 99 | block = self._verify_block(block) 100 | self.blocks.insert(index, block) 101 | 102 | def append(self, block: "EditorJSBlock"): 103 | block = self._verify_block(block) 104 | self.blocks.append(block) 105 | 106 | def _verify_block(self, block: Union["EditorJSBlock", dict]): 107 | if block.get("type") not in self._features: 108 | raise ValueError(f"Unknown feature: {block.get('type')}") 109 | 110 | if block.get("id") is None: 111 | raise ValueError("Block ID not set") 112 | 113 | if not isinstance(block, EditorJSBlock)\ 114 | and isinstance(block, dict): 115 | 116 | block = self._features[block["type"]].create_block( 117 | list(self._features.keys()), 118 | block 119 | ) 120 | 121 | return block 122 | -------------------------------------------------------------------------------- /docs/tunes.md: -------------------------------------------------------------------------------- 1 | # Integrating Text Alignment Tune in EditorJS 2 | 3 | Let's walk through the process of adding a text alignment tune to EditorJS. This tune allows content creators to align text within blocks. We'll break down the implementation into digestible steps, explaining the purpose and functionality of each method involved. It does already exist in `wagtail_editorjs`, so we highly recommend you experiment with your own custom tunes! 4 | 5 | ## Step 1: Import Required Modules 6 | 7 | Start by importing the necessary Python classes and modules. This setup involves Django for form validation, typing for type annotations, and specific classes from Wagtail and the EditorJS integration: 8 | 9 | ```python 10 | from typing import Any 11 | from django import forms 12 | from wagtail_editorjs.hooks import REGISTER_HOOK_NAME 13 | from wagtail_editorjs.registry import EditorJSFeatures, EditorJSTune, EditorJSElement 14 | from wagtail import hooks 15 | ``` 16 | 17 | ## Step 2: Define the `AlignmentBlockTune` Class 18 | 19 | This class is the core of our tune, extending `EditorJSTune` to add text alignment functionality: 20 | 21 | ```python 22 | class AlignmentBlockTune(EditorJSTune): 23 | allowed_attributes = {"*": ["class"]} 24 | klass = "AlignmentBlockTune" 25 | js = ["https://cdn.jsdelivr.net/npm/editorjs-text-alignment-blocktune@latest"] 26 | ``` 27 | 28 | ### The `validate` Method 29 | 30 | This method ensures the provided alignment value is valid. It checks if the alignment value is one of the accepted options: left, center, or right. If not, it raises a form validation error: 31 | 32 | ```python 33 | def validate(self, data: Any): 34 | super().validate(data) 35 | alignment = data.get("alignment") 36 | if alignment not in ["left", "center", "right"]: 37 | raise forms.ValidationError("Invalid alignment value") 38 | ``` 39 | 40 | ### The `tune_element` Method 41 | 42 | This method applies the alignment tune to an element. It adjusts the class attribute of the element to include the specified alignment, dynamically adding styling to align content as desired. 43 | The element returned from the tune is what gets used further in processing. You can add classes to the element; or return a different element entirely, for example by wrapping it. 44 | 45 | ```python 46 | def tune_element(self, element: EditorJSElement, tune_value: Any, context=None) -> EditorJSElement: 47 | element = super().tune_element(element, tune_value, context=context) 48 | element.add_attributes(class_=f"align-content-{tune_value['alignment'].strip()}") 49 | return element 50 | ``` 51 | 52 | ## Step 3: Register the Tune with EditorJS 53 | 54 | Finally, we register the tune to make it available for use in EditorJS. This involves adding it to the EditorJSFeatures registry and specifying configurations like the default alignment: 55 | 56 | ```python 57 | @hooks.register(REGISTER_HOOK_NAME) 58 | def register_editor_js_features(registry: EditorJSFeatures): 59 | registry.register( 60 | "text-alignment-tune", 61 | AlignmentBlockTune( 62 | "text-alignment-tune", 63 | inlineToolbar=True, 64 | config={ 65 | "default": "left", 66 | }, 67 | ), 68 | ) 69 | 70 | # To apply globally to all features 71 | registry.register_tune("text-alignment-tune") 72 | 73 | # Or optionally for a specific feature remove the wildcard above 74 | # and use the following (given the features "header" and "paragraph" are used in the editor) 75 | # registry.register_tune("text-alignment-tune", "header") 76 | # registry.register_tune("text-alignment-tune", "paragraph") 77 | 78 | ``` 79 | 80 | By following these steps, you've added a text alignment tune to EditorJS, enhancing your text blocks with alignment options. This feature not only improves the appearance of your content but also adds to the overall user experience by providing more control over text presentation. 81 | -------------------------------------------------------------------------------- /.GITIGNORE: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # Static/media files for testapp 30 | wagtail_editorjs/test/assets/* 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .nox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | *.py,cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | cover/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | db.sqlite3-journal 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | .pybuilder/ 79 | target/ 80 | 81 | # Jupyter Notebook 82 | .ipynb_checkpoints 83 | 84 | # IPython 85 | profile_default/ 86 | ipython_config.py 87 | 88 | # pyenv 89 | # For a library or package, you might want to ignore these files since the code is 90 | # intended to run in multiple environments; otherwise, check them in: 91 | # .python-version 92 | 93 | # pipenv 94 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 95 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 96 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 97 | # install all needed dependencies. 98 | #Pipfile.lock 99 | 100 | # poetry 101 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 102 | # This is especially recommended for binary packages to ensure reproducibility, and is more 103 | # commonly ignored for libraries. 104 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 105 | #poetry.lock 106 | 107 | # pdm 108 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 109 | #pdm.lock 110 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 111 | # in version control. 112 | # https://pdm.fming.dev/#use-with-ide 113 | .pdm.toml 114 | 115 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 116 | __pypackages__/ 117 | 118 | # Celery stuff 119 | celerybeat-schedule 120 | celerybeat.pid 121 | 122 | # SageMath parsed files 123 | *.sage.py 124 | 125 | # Environments 126 | .env 127 | .venv 128 | env/ 129 | venv/ 130 | ENV/ 131 | env.bak/ 132 | venv.bak/ 133 | 134 | # Spyder project settings 135 | .spyderproject 136 | .spyproject 137 | 138 | # Rope project settings 139 | .ropeproject 140 | 141 | # mkdocs documentation 142 | /site 143 | 144 | # mypy 145 | .mypy_cache/ 146 | .dmypy.json 147 | dmypy.json 148 | 149 | # Pyre type checker 150 | .pyre/ 151 | 152 | # pytype static type analyzer 153 | .pytype/ 154 | 155 | # Cython debug symbols 156 | cython_debug/ 157 | 158 | # VSCode settings 159 | .vscode/ 160 | 161 | # PyCharm 162 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 163 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 164 | # and can be added to the global gitignore or merged into this file. For a more nuclear 165 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 166 | #.idea/ -------------------------------------------------------------------------------- /wagtail_editorjs/static/wagtail_editorjs/js/tools/wagtail-chooser-tool.js: -------------------------------------------------------------------------------- 1 | class BaseWagtailChooserTool { 2 | constructor({ api, config }) { 3 | if (!config) { 4 | config = { 5 | chooserId: null, 6 | }; 7 | } 8 | 9 | if (!config.chooserId) { 10 | console.error('chooserId is required'); 11 | throw new Error('chooserId is required'); 12 | } 13 | 14 | this.button = null; 15 | this._state = false; 16 | this.api = api; 17 | this.tag = 'A'; 18 | this.tagClass = `wagtail-${this.constructor["chooserType"]}-link`; 19 | this.config = config; 20 | this.chooser = null; 21 | } 22 | 23 | static get isInline() { 24 | return true; 25 | } 26 | 27 | get state() { 28 | return this._state; 29 | } 30 | 31 | static get sanitize() { 32 | return { 33 | a: true, 34 | }; 35 | } 36 | 37 | set state(state) { 38 | this._state = state; 39 | this.button.classList.toggle(this.api.styles.inlineToolButtonActive, state); 40 | } 41 | 42 | setDataOnWrapper(wrapperTag, data) { 43 | if (!data) { 44 | return; 45 | } 46 | wrapperTag.href = data.url; 47 | wrapperTag.dataset[this.constructor["chooserType"]] = true; 48 | 49 | for (const key in data) { 50 | if (data[key]) { 51 | wrapperTag.dataset[key] = data[key]; 52 | } else { 53 | delete wrapperTag.dataset[key]; 54 | } 55 | } 56 | } 57 | 58 | render() { 59 | this.button = document.createElement('button'); 60 | this.button.type = 'button'; 61 | this.button.innerHTML = this.iconHTML; 62 | this.button.dataset.chooserActionChoose = 'true'; 63 | this.button.classList.add( 64 | this.api.styles.inlineToolButton, 65 | ) 66 | 67 | this.chooser = this.newChooser(); 68 | 69 | return this.button; 70 | } 71 | 72 | surround(range) { 73 | if (this.state) { 74 | this.unwrap(range); 75 | return; 76 | } 77 | 78 | let chooserEventListener = null; 79 | chooserEventListener = (e) => { 80 | const data = this.chooser.state; 81 | this.wrap(range, data); 82 | this.chooser.input.removeEventListener('change', chooserEventListener); 83 | }; 84 | this.chooser.openChooserModal(); 85 | this.chooser.input.addEventListener('change', chooserEventListener); 86 | } 87 | 88 | wrap(range, state) { 89 | let selectedText = range.extractContents(); 90 | 91 | const previousWrapperTag = this.api.selection.findParentTag(this.tag); 92 | if (previousWrapperTag || previousWrapperTag && previousWrapperTag.querySelector(this.tag.toLowerCase())) { 93 | previousWrapperTag.remove(); 94 | } 95 | 96 | const wrapperTag = document.createElement(this.tag); 97 | this.wrapperTag = wrapperTag; 98 | 99 | this.setDataOnWrapper(wrapperTag, state); 100 | 101 | wrapperTag.classList.add(this.tagClass); 102 | wrapperTag.appendChild(selectedText); 103 | range.insertNode(wrapperTag); 104 | 105 | this.api.selection.expandToTag(wrapperTag); 106 | } 107 | 108 | unwrap(range) { 109 | const wrapperTag = this.api.selection.findParentTag(this.tag); 110 | const text = range.extractContents(); 111 | wrapperTag.remove(); 112 | range.insertNode(text); 113 | } 114 | 115 | checkState() { 116 | const wrapperTag = this.api.selection.findParentTag(this.tag, this.tagClass); 117 | 118 | this.state = !!wrapperTag; 119 | 120 | if (this.state) { 121 | this.showActions(wrapperTag); 122 | } else { 123 | this.hideActions(); 124 | } 125 | } 126 | } 127 | 128 | window.BaseWagtailChooserTool = BaseWagtailChooserTool; -------------------------------------------------------------------------------- /wagtail_editorjs/static/wagtail_editorjs/js/tools/wagtail-block.js: -------------------------------------------------------------------------------- 1 | (function(blockName, blockTitle) { 2 | const wagtailBlockIcon = ` 3 | 4 | 5 | 6 | 7 | `; 8 | 9 | class WagtailBlockTool extends window.BaseWagtailEditorJSTool { 10 | constructor({ data, api, config, block }) { 11 | 12 | if (!data) { 13 | data = {}; 14 | } 15 | 16 | if (!data["block"]) { 17 | data["block"] = {}; 18 | } 19 | 20 | super({ data, api, config, block }); 21 | 22 | this.settings = [ 23 | 24 | ]; 25 | 26 | this.initSettings(); 27 | } 28 | 29 | static get toolbox() { 30 | return { 31 | title: blockTitle, // Title for the block 32 | icon: wagtailBlockIcon, 33 | }; 34 | } 35 | 36 | render() { 37 | this.wrapperElement = window.makeElement('div', { 38 | className: 'wagtail-block-feature-wrapper', 39 | }); 40 | 41 | this.blockPrefix = `${blockName}-${Math.random().toString(36).substring(7)}`; 42 | 43 | const html = this.config.rendered.replace( 44 | /__PREFIX__/g, 45 | this.blockPrefix, 46 | ); 47 | 48 | this.wrapperElement.innerHTML = html; 49 | 50 | const element = this.wrapperElement.querySelector(`#${this.blockPrefix}`); 51 | const id = element.id; 52 | // 53 | if (!window.telepath) { 54 | console.error('Telepath is not defined'); 55 | return; 56 | } 57 | // 58 | // const dataValue = JSON.parse(element.getAttribute('data-w-block-data-value')); 59 | // const argumentsValue = JSON.parse(element.getAttribute('data-w-block-arguments-value')); 60 | if (element.dataset.controller) { 61 | delete element.dataset.controller; 62 | } 63 | const dataValue = JSON.parse(element.dataset.wBlockDataValue); 64 | const argumentsValue = JSON.parse(element.dataset.wBlockArgumentsValue); 65 | this.blockDef = telepath.unpack(dataValue); 66 | 67 | this.wrapperElement.addEventListener('DOMNodeInserted', (e) => { 68 | if (!(e.relatedNode.firstElementChild == this.wrapperElement)) { 69 | return; 70 | } 71 | 72 | // Wait for the element block to be rendered by the browser 73 | setTimeout(() => { 74 | this.block = this.blockDef.render( 75 | element, id, ...argumentsValue, 76 | ) 77 | if (this.data) { 78 | this.block.setState(this.data["block"]); 79 | } 80 | }, 0); 81 | }); 82 | 83 | return super.render(); 84 | } 85 | 86 | validate(savedData) { 87 | return true; 88 | } 89 | 90 | save(blockContent) { 91 | this.data = super.save(blockContent); 92 | if (!this.block.getState) { 93 | console.error('Block does not have a getState method', this.block) 94 | } else { 95 | this.data["block"] = this.block.getValue(); 96 | } 97 | return this.data || {}; 98 | } 99 | } 100 | 101 | window[`WagtailBlockTool_${blockName}`] = WagtailBlockTool; 102 | 103 | })( 104 | document.currentScript.getAttribute('data-name'), 105 | document.currentScript.getAttribute('data-title'), 106 | ); -------------------------------------------------------------------------------- /wagtail_editorjs/test/testapp/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for testapp project. 3 | 4 | Generated by 'django-admin startproject' using Django 5.0.2. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/5.0/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/5.0/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | BASE_DIR = Path(__file__).resolve().parent.parent 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = 'test_key_wagtail_editorjs' 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Tests will not pass if true. 32 | # We do not account for it in test files. 33 | # We have tests for attributes already. 34 | EDITORJS_ADD_BLOCK_ID = False 35 | 36 | # Application definition 37 | INSTALLED_APPS = [ 38 | 'wagtail_editorjs', 39 | 'wagtail_editorjs.test.core', 40 | 41 | 'wagtail', 42 | 'wagtail.sites', 43 | 'wagtail.users', 44 | 'wagtail.admin', 45 | 'wagtail.documents', 46 | 'wagtail.images', 47 | 48 | 'modelcluster', 49 | 'taggit', 50 | 51 | 'django.contrib.admin', 52 | 'django.contrib.auth', 53 | 'django.contrib.contenttypes', 54 | 'django.contrib.sessions', 55 | 'django.contrib.messages', 56 | 'django.contrib.staticfiles', 57 | ] 58 | 59 | MIDDLEWARE = [ 60 | 'django.middleware.security.SecurityMiddleware', 61 | 'django.contrib.sessions.middleware.SessionMiddleware', 62 | 'django.middleware.common.CommonMiddleware', 63 | 'django.middleware.csrf.CsrfViewMiddleware', 64 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 65 | 'django.contrib.messages.middleware.MessageMiddleware', 66 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 67 | ] 68 | 69 | ROOT_URLCONF = 'testapp.urls' 70 | 71 | TEMPLATES = [ 72 | { 73 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 74 | 'DIRS': [], 75 | 'APP_DIRS': True, 76 | 'OPTIONS': { 77 | 'context_processors': [ 78 | 'django.template.context_processors.debug', 79 | 'django.template.context_processors.request', 80 | 'django.contrib.auth.context_processors.auth', 81 | 'django.contrib.messages.context_processors.messages', 82 | ], 83 | }, 84 | }, 85 | ] 86 | 87 | WSGI_APPLICATION = 'testapp.wsgi.application' 88 | 89 | 90 | # Database 91 | # https://docs.djangoproject.com/en/5.0/ref/settings/#databases 92 | 93 | DATABASES = { 94 | 'default': { 95 | 'ENGINE': 'django.db.backends.sqlite3', 96 | 'NAME': BASE_DIR / 'test' / 'db.sqlite3', 97 | } 98 | } 99 | 100 | STATIC_URL = '/static/' 101 | STATIC_ROOT = BASE_DIR / 'assets/static' 102 | MEDIA_URL = '/media/' 103 | MEDIA_ROOT = BASE_DIR / 'assets/media' 104 | 105 | # Password validation 106 | # https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators 107 | 108 | AUTH_PASSWORD_VALIDATORS = [ 109 | { 110 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 111 | }, 112 | { 113 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 114 | }, 115 | { 116 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 117 | }, 118 | { 119 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 120 | }, 121 | ] 122 | 123 | 124 | # Internationalization 125 | # https://docs.djangoproject.com/en/5.0/topics/i18n/ 126 | 127 | LANGUAGE_CODE = 'en-us' 128 | 129 | TIME_ZONE = 'UTC' 130 | 131 | USE_I18N = True 132 | 133 | USE_TZ = True 134 | 135 | 136 | # Static files (CSS, JavaScript, Images) 137 | # https://docs.djangoproject.com/en/5.0/howto/static-files/ 138 | 139 | STATIC_URL = 'static/' 140 | 141 | # Default primary key field type 142 | # https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field 143 | 144 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 145 | -------------------------------------------------------------------------------- /wagtail_editorjs/static/wagtail_editorjs/js/editorjs-widget.js: -------------------------------------------------------------------------------- 1 | window.RegisteredEditorJSInitializers = window.RegisteredEditorJSInitializers || []; 2 | 3 | function registerInitializer(initializer) { 4 | window.RegisteredEditorJSInitializers.push(initializer); 5 | }; 6 | 7 | window.registerInitializer = registerInitializer; 8 | 9 | function newEvent(eventName, data) { 10 | return new CustomEvent(`editorjs:${eventName}`, {detail: data}); 11 | } 12 | 13 | class EditorJSWidget { 14 | constructor(elementWrapper, hiddenInput, config) { 15 | this.element = hiddenInput; 16 | this.id = elementWrapper.id; 17 | this.config = config; 18 | 19 | hiddenInput.CurrentWidget = this; 20 | elementWrapper.CurrentWidget = this; 21 | 22 | this.initEditor(); 23 | } 24 | 25 | initEditor() { 26 | this.editorConfig = { 27 | ...this.config, 28 | onReady: async () => { 29 | const editorData = await this.editor.save(); 30 | this.element.value = JSON.stringify(editorData); 31 | 32 | this.dispatchEvent('ready', { 33 | data: editorData, 34 | }); 35 | 36 | for (let i = 0; i < window.RegisteredEditorJSInitializers.length; i++) { 37 | 38 | const initializer = window.RegisteredEditorJSInitializers[i]; 39 | try { 40 | initializer(this); 41 | } catch (e) { 42 | console.error(`Failed to initialize EditorJS widget (${i}): ${e}`); 43 | } 44 | } 45 | }, 46 | onChange: async () => { 47 | const editorData = await this.editor.save(); 48 | this.element.value = JSON.stringify(editorData); 49 | 50 | this.dispatchEvent('change', { 51 | data: editorData, 52 | }); 53 | }, 54 | }; 55 | 56 | if (this.element.value) { 57 | this.editorConfig.data = JSON.parse(this.element.value); 58 | } 59 | 60 | if (!window.editors){ 61 | window.editors = []; 62 | } 63 | window.editors.push(this); 64 | 65 | const formButtons = $('[data-edit-form] :submit'); 66 | let clickedFormSaveButton = false; 67 | formButtons.on('click', (e) => { 68 | if (clickedFormSaveButton) { 69 | return; 70 | } 71 | 72 | e.preventDefault(); 73 | e.stopPropagation(); 74 | 75 | this.editor.save().then((outputData) => { 76 | this.element.value = JSON.stringify(outputData); 77 | clickedFormSaveButton = true; 78 | e.currentTarget.click(); 79 | }).catch((reason) => { 80 | alert(`Failed to save EditorJS data: ${reason}`); 81 | }); 82 | }); 83 | 84 | this.editor = new EditorJS(this.editorConfig); 85 | this.element.setAttribute('data-editorjs-initialized', 'true'); 86 | this.element.CurrentEditor = this.editor; 87 | 88 | this.editor.isReady.then(() => {}).catch((reason) => { 89 | 90 | this.dispatchEvent('error', {reason: reason}); 91 | console.error(`Editor.js failed to initialize: ${reason}`); 92 | console.log(this.editorConfig) 93 | 94 | }); 95 | } 96 | 97 | dispatchEvent(eventName, data = null) { 98 | if (!data) { 99 | data = {}; 100 | }; 101 | 102 | data.editor = this.editor; 103 | data.widget = this; 104 | 105 | const event = new CustomEvent( 106 | `editorjs:${eventName}`, 107 | {detail: data}, 108 | ); 109 | 110 | this.element.dispatchEvent(event); 111 | } 112 | 113 | async getState() { 114 | return await this.editor.save(); 115 | } 116 | 117 | setState(data) { 118 | this.editor.render(data); 119 | } 120 | 121 | getValue() { 122 | return this.element.value; 123 | } 124 | 125 | focus() { 126 | this.editor.focus(); 127 | } 128 | 129 | blur() { 130 | this.editor.blur(); 131 | } 132 | 133 | disconnect() { 134 | this.editor.destroy(); 135 | } 136 | } 137 | 138 | window.EditorJSWidget = EditorJSWidget; -------------------------------------------------------------------------------- /wagtail_editorjs/features/tunes.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from django import forms 3 | from django.utils.translation import gettext_lazy as _ 4 | 5 | from ..registry import ( 6 | EditorJSTune, 7 | EditorJSElement, 8 | wrapper, 9 | ) 10 | 11 | 12 | class AlignmentBlockTune(EditorJSTune): 13 | allowed_attributes = { 14 | "*": ["class"], 15 | } 16 | klass = "AlignmentBlockTune" 17 | js = [ 18 | "wagtail_editorjs/vendor/editorjs/tools/text-alignment.js", 19 | ] 20 | 21 | def validate(self, data: Any): 22 | super().validate(data) 23 | alignment = data.get("alignment") 24 | if alignment not in ["left", "center", "right"]: 25 | raise forms.ValidationError("Invalid alignment value") 26 | 27 | def tune_element(self, element: EditorJSElement, tune_value: Any, context = None) -> EditorJSElement: 28 | element = super().tune_element(element, tune_value, context=context) 29 | element.add_attributes(class_=f"align-content-{tune_value['alignment'].strip()}") 30 | return element 31 | 32 | 33 | class TextVariantTune(EditorJSTune): 34 | allowed_tags = ["div"] 35 | allowed_attributes = ["class"] 36 | klass = "TextVariantTune" 37 | js = [ 38 | "wagtail_editorjs/vendor/editorjs/tools/text-variant-tune.js", 39 | ] 40 | 41 | def validate(self, data: Any): 42 | super().validate(data) 43 | if not data: 44 | return 45 | 46 | if data not in [ 47 | "call-out", 48 | "citation", 49 | "details", 50 | ]: 51 | raise forms.ValidationError("Invalid text variant value") 52 | 53 | def tune_element(self, element: EditorJSElement, tune_value: Any, context = None) -> EditorJSElement: 54 | element = super().tune_element(element, tune_value, context=context) 55 | 56 | if not tune_value: 57 | return element 58 | 59 | if element.is_wrapped: 60 | element["class"] = f"text-variant-{tune_value}" 61 | 62 | return EditorJSElement( 63 | "div", 64 | element, 65 | attrs={"class": f"text-variant-{tune_value}"}, 66 | ) 67 | 68 | 69 | class ColorTune(EditorJSTune): 70 | allowed_attributes = { 71 | "*": ["class", "style"], 72 | } 73 | js = [ 74 | "wagtail_editorjs/js/tools/wagtail-color-tune.js", 75 | ] 76 | klass = "WagtailTextColorTune" 77 | 78 | def validate(self, data: Any): 79 | super().validate(data) 80 | if not data: 81 | return 82 | 83 | if not isinstance(data, dict): 84 | raise forms.ValidationError("Invalid color value") 85 | 86 | if "color" not in data: 87 | # Dont do anything 88 | return 89 | 90 | if not isinstance(data["color"], str): 91 | raise forms.ValidationError("Invalid color value") 92 | 93 | if not data["color"].startswith("#"): 94 | raise forms.ValidationError("Invalid color value") 95 | 96 | def tune_element(self, element: EditorJSElement, tune_value: Any, context = None) -> EditorJSElement: 97 | if "color" not in tune_value: 98 | return element 99 | 100 | return wrapper( 101 | element, 102 | attrs={ 103 | "class": "wagtail-editorjs-color-tuned", 104 | "style": { 105 | "--text-color": tune_value["color"], 106 | }, 107 | }, 108 | ) 109 | 110 | class BackgroundColorTune(ColorTune): 111 | klass = "WagtailBackgroundColorTune" 112 | 113 | def tune_element(self, element: EditorJSElement, tune_value: Any, context = None) -> EditorJSElement: 114 | 115 | if "color" not in tune_value: 116 | return element 117 | 118 | classname = [ 119 | "wagtail-editorjs-color-tuned", 120 | ] 121 | 122 | attrs = { 123 | "class": classname, 124 | "style": { 125 | "--background-color": tune_value["color"], 126 | }, 127 | } 128 | 129 | if tune_value.get("stretched", None): 130 | attrs["class"] = classname + ["bg-stretched"] 131 | 132 | return wrapper( 133 | element, 134 | attrs=attrs, 135 | ) 136 | -------------------------------------------------------------------------------- /wagtail_editorjs/static/wagtail_editorjs/js/tools/wagtail-inline-tool.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {Object} TagObject 3 | * @property {string} name 4 | * @property {string} class 5 | * 6 | * @typedef {Object} EditorActionObject 7 | * @property {Function} renderActions 8 | * @property {Function} showActions 9 | * @property {Function} hideActions 10 | */ 11 | 12 | 13 | class BaseWagtailInlineTool { 14 | /** 15 | * Provides a simple way to create an EditorJS inline tool. 16 | * 17 | * Must be extended by a class that implements the following: 18 | * 19 | * Attributes: 20 | * 21 | * - iconHTML: string 22 | * - tag: object 23 | * 24 | * Methods: 25 | * 26 | * - showActions(wrapperTag: HTMLElement): void 27 | * - hideActions(): void 28 | * - setDataOnWrapper(wrapperTag: HTMLElement): void 29 | * - renderTag(range: Range, selectedText: DocumentFragment): HTMLElement 30 | */ 31 | constructor({ api, config }) { 32 | if (!config) { 33 | config = {}; 34 | } 35 | 36 | this.button = null; 37 | this._state = false; 38 | this.api = api; 39 | this.config = config; 40 | 41 | this.tag = { 42 | name: '', 43 | class: '', 44 | }; 45 | } 46 | 47 | static get isInline() { 48 | return true; 49 | } 50 | 51 | set state(state) { 52 | this._state = state; 53 | this.button.classList.toggle(this.api.styles.inlineToolButtonActive, state); 54 | } 55 | 56 | get state() { 57 | return this._state; 58 | } 59 | 60 | /** 61 | * @returns {string} 62 | * @readonly 63 | * @abstract 64 | * @throws {Error} if not implemented 65 | * @description 66 | * Returns the HTML for the icon of the inline tool. 67 | */ 68 | get iconHTML() { 69 | throw new Error('iconHTML not implemented'); 70 | } 71 | 72 | /** 73 | * @returns {TagObject} 74 | * @throws {Error} if tag name or class is not defined 75 | * @readonly 76 | */ 77 | get _tag() { 78 | if (!this.tag.name) { 79 | throw new Error('Tag name is not defined'); 80 | } 81 | if (!this.tag.class) { 82 | throw new Error('Tag class is not defined'); 83 | } 84 | return this.tag; 85 | } 86 | 87 | /** 88 | * @returns {HTMLElement} 89 | */ 90 | render() { 91 | this.button = document.createElement('button'); 92 | this.button.type = 'button'; 93 | this.button.innerHTML = this.iconHTML; 94 | this.button.classList.add( 95 | this.api.styles.inlineToolButton, 96 | ) 97 | 98 | return this.button; 99 | } 100 | 101 | /** 102 | * @param {Range} range 103 | */ 104 | surround(range) { 105 | if (this.state) { 106 | this.unwrap(range); 107 | return; 108 | } 109 | this.wrap(range); 110 | } 111 | 112 | /** 113 | * @param {Range} range 114 | */ 115 | wrap(range) { 116 | let selectedText = range.extractContents(); 117 | const wrapperTag = this.renderTag(range, selectedText); 118 | 119 | range.insertNode(wrapperTag); 120 | 121 | this.api.selection.expandToTag(wrapperTag); 122 | } 123 | 124 | /** 125 | * @param {Range} range 126 | */ 127 | unwrap(range) { 128 | const wrapperTag = this.api.selection.findParentTag(this._tag.name, this._tag.class); 129 | 130 | // Extract the text from the wrapper tag 131 | const text = range.extractContents(); 132 | 133 | // Remove the wrapper tag 134 | wrapperTag.remove(); 135 | 136 | // Re-insert the old text 137 | range.insertNode(text); 138 | 139 | // wrapperTag.replaceWith(...wrapperTag.childNodes); 140 | } 141 | 142 | checkState() { 143 | const wrapperTag = this.api.selection.findParentTag( 144 | this._tag.name, this._tag.class, 145 | ); 146 | 147 | this.state = !!wrapperTag; 148 | } 149 | 150 | /** 151 | * @param {Range} range 152 | * @param {DocumentFragment} selectedText 153 | * @returns {HTMLElement} 154 | */ 155 | renderTag(range, selectedText) { 156 | const wrapperTag = document.createElement(this._tag.name); 157 | this.wrapperTag = wrapperTag; 158 | wrapperTag.classList.add(this._tag.class); 159 | wrapperTag.appendChild(selectedText); 160 | this.setDataOnWrapper(range, wrapperTag); 161 | return wrapperTag; 162 | } 163 | 164 | /** 165 | * @param {Range} range 166 | * @param {HTMLElement} wrapperTag 167 | */ 168 | setDataOnWrapper(range, wrapperTag) { 169 | 170 | } 171 | 172 | } 173 | 174 | window.BaseWagtailInlineTool = BaseWagtailInlineTool; 175 | -------------------------------------------------------------------------------- /docs/editorjs_feature.md: -------------------------------------------------------------------------------- 1 | # Add an EditorJS feature 2 | 3 | In this section we are going to add an already defined EditorJS feature to the list of supported features. 4 | We will add the [simple-image](https://github.com/editor-js/simple-image) feature. 5 | 6 | First we will register the feature with our editor. 7 | To keep things consistent; we can import the hook name from `wagtail_editorjs.hooks`. 8 | Let's import everything first. 9 | 10 | ```python 11 | from typing import Any 12 | from django import forms 13 | from wagtail_editorjs.hooks import REGISTER_HOOK_NAME 14 | from wagtail_editorjs.registry import ( 15 | EditorJSFeature, EditorJSFeatures, 16 | EditorJSElement, EditorJSBlock, 17 | ) 18 | from wagtail import hooks 19 | ``` 20 | 21 | We can now get started creating the feature itself. 22 | As seen from the data-format in the github package, the feature requires the following fields: 23 | 24 | - `url`: The URL of the image. 25 | - `caption`: The caption of the image. 26 | - `withBorder`: A boolean value to determine if the image should have a border. 27 | - `withBackground`: A boolean value to determine if the image should have a background. 28 | - `stretched`: A boolean value to determine if the image should be stretched. 29 | 30 | We will create a new class `CustomImageFeature` that extends `EditorJSFeature`. 31 | For now we will only override the `validate` method. 32 | 33 | We will also set the required attributes for cleaning. 34 | 35 | ```python 36 | class CustomImageFeature(EditorJSFeature): 37 | # These tags are allowed and will not be cleaned by bleach if enabled. 38 | allowed_tags = ["img"] 39 | allowed_attributes = ["src", "alt", "style"] 40 | 41 | def get_config(self, context: dict[str, Any]): 42 | # This context is always present. 43 | # It is the widget context - NOT the request context. 44 | config = super().get_config() or {} 45 | config["config"] = {} # my custom configuration 46 | return config 47 | 48 | def validate(self, data: Any): 49 | super().validate(data) 50 | 51 | if 'url' not in data['data']: 52 | raise forms.ValidationError('Invalid data.url value') 53 | 54 | if "caption" not in data["data"]: 55 | raise forms.ValidationError('Invalid data.caption value') 56 | 57 | ... 58 | ``` 59 | 60 | Next we can override the `render_block_data` method. 61 | 62 | This method is used to render the block for display to the user on the frontend. 63 | 64 | ```python 65 | def render_block_data(self, block: EditorJSBlock, context = None) -> EditorJSElement: 66 | # Context is not guaranteed to be present. This is the request context. 67 | return EditorJSElement( 68 | "img", 69 | close_tag=False, 70 | attrs={ 71 | "src": block["data"]["url"], 72 | "alt": block["data"]["caption"], 73 | "style": { 74 | "border": "1px solid black" if block["data"]["withBorder"] else "none", 75 | "background-color": "lightgray" if block["data"]["withBackground"] else "none", 76 | "width": "100%" if block["data"]["stretched"] else "auto", 77 | } 78 | }, 79 | ) 80 | ``` 81 | 82 | We also provide a way to easily test this feature. 83 | 84 | All registered features are tested automatically if their `get_test_data` method returns data. 85 | 86 | ```python 87 | @classmethod 88 | def get_test_data(cls): 89 | return [ 90 | { 91 | "url": "https://www.example.com/image.jpg", 92 | "caption": "Example image", 93 | "withBorder": True, 94 | "withBackground": True, 95 | "stretched": True, 96 | }, 97 | ] 98 | ``` 99 | 100 | 101 | 102 | We can now register the feature with the editor. 103 | 104 | The feature will be imported from a CDN provided on the package README. 105 | 106 | ```python 107 | 108 | @hooks.register(REGISTER_HOOK_NAME) 109 | def register_editorjs_features(features: EditorJSFeatures): 110 | # The feature name as you'd like to use in your field/block. 111 | feature_name = "simple-image" 112 | 113 | # The classname as defined in javascript. 114 | # This is accessed with `window.[feature_js_class]`. 115 | # In this case; `window.SimpleImage`. 116 | feature_js_class = "SimpleImage" 117 | 118 | # Register the feature with the editor. 119 | features.register( 120 | feature_name, 121 | CustomImageFeature( 122 | feature_name, 123 | feature_js_class, 124 | js = [ 125 | # Import from CDN 126 | "https://cdn.jsdelivr.net/npm/@editorjs/simple-image", 127 | ], 128 | ), 129 | ) 130 | ``` 131 | 132 | If you now paste a URL with an image in the editor, you should see the image rendered with the caption. 133 | -------------------------------------------------------------------------------- /wagtail_editorjs/static/wagtail_editorjs/js/tools/tooltips/wagtail-tooltips.js: -------------------------------------------------------------------------------- 1 | const WagtailTooltipIconHtml = ` 2 | 3 | 4 | 5 | 6 | ` 7 | 8 | 9 | class WagtailTooltip extends window.BaseWagtailInlineTool { 10 | constructor({ api, config }) { 11 | 12 | super({ api, config }); 13 | 14 | this.tag = { 15 | name: 'SPAN', 16 | class: 'wagtail-tooltip', 17 | }; 18 | } 19 | 20 | static get sanitize() { 21 | return { 22 | "span": { 23 | "class": "wagtail-tooltip", 24 | "data-controller": "w-tooltip", 25 | "data-w-tooltip-content-value": true, 26 | "data-w-tooltip-placement-value": true, 27 | }, 28 | }; 29 | } 30 | 31 | get iconHTML() { 32 | return WagtailTooltipIconHtml; 33 | } 34 | 35 | checkState() { 36 | const wrapperTag = this.api.selection.findParentTag( 37 | this._tag.name, this._tag.class, 38 | ); 39 | 40 | this.state = !!wrapperTag; 41 | 42 | if (this.state) { 43 | this.showActions(wrapperTag); 44 | } else { 45 | this.hideActions(); 46 | } 47 | } 48 | 49 | renderActions() { 50 | this.tooltipInputWrapper = document.createElement('div'); 51 | this.tooltipInputWrapper.classList.add( 52 | "wagtail-button-wrapper", 53 | ); 54 | this.tooltipInputWrapper.hidden = true; 55 | 56 | this.tooltipInput = document.createElement('input'); 57 | this.tooltipInput.type = 'text'; 58 | this.tooltipInput.placeholder = this.api.i18n.t( 59 | 'Enter tooltip text', 60 | ); 61 | this.tooltipInput.classList.add( 62 | this.api.styles.input, 63 | ); 64 | 65 | this.tooltipInputPosition = document.createElement('select'); 66 | this.tooltipInputPosition.classList.add( 67 | ); 68 | 69 | const choices = [ 70 | ['top', this.api.i18n.t('Top')], 71 | ['right', this.api.i18n.t('Right')], 72 | ['bottom', this.api.i18n.t('Bottom')], 73 | ['left', this.api.i18n.t('Left')], 74 | ]; 75 | 76 | choices.forEach(([value, label]) => { 77 | const option = document.createElement('option'); 78 | option.value = value; 79 | option.innerText = label; 80 | this.tooltipInputPosition.appendChild(option); 81 | }); 82 | 83 | this.tooltipInputWrapper.appendChild( 84 | this.tooltipInput, 85 | ); 86 | 87 | this.tooltipInputWrapper.appendChild( 88 | this.tooltipInputPosition, 89 | ); 90 | 91 | return this.tooltipInputWrapper; 92 | } 93 | 94 | showActions(wrapperTag) { 95 | this.tooltipInputWrapper.hidden = false; 96 | 97 | // Tooltip input element 98 | this.tooltipInput.oninput = (e) => { 99 | if (!wrapperTag.dataset.controller && e.target.value) { 100 | wrapperTag.dataset.controller = "w-tooltip"; 101 | } else if (wrapperTag.dataset.controller && !e.target.value) { 102 | delete wrapperTag.dataset.controller; 103 | } 104 | wrapperTag.dataset.wTooltipContentValue = e.target.value; 105 | }; 106 | 107 | // Position select element 108 | this.tooltipInputPosition.onchange = (e) => { 109 | wrapperTag.dataset.wTooltipPlacementValue = e.target.value; 110 | }; 111 | 112 | // Set initial content value 113 | if (wrapperTag.dataset.wTooltipContentValue) { 114 | this.tooltipInput.value = wrapperTag.dataset.wTooltipContentValue; 115 | } else { 116 | this.tooltipInput.value = wrapperTag.innerText; 117 | this.tooltipInput.dispatchEvent( 118 | new Event('input'), 119 | ); 120 | } 121 | 122 | // Set initial position 123 | this.tooltipInputPosition.value = ( 124 | wrapperTag.dataset.wTooltipPlacementValue || 'bottom' 125 | ) 126 | } 127 | 128 | hideActions() { 129 | this.tooltipInputWrapper.hidden = true; 130 | this.tooltipInputPosition.onchange = null; 131 | this.tooltipInput.oninput = null; 132 | this.tooltipInput.value = ''; 133 | } 134 | } 135 | 136 | window.WagtailTooltip = WagtailTooltip; -------------------------------------------------------------------------------- /wagtail_editorjs/test/core/tests/test_blocks.py: -------------------------------------------------------------------------------- 1 | from wagtail import blocks 2 | from django.template.loader import render_to_string 3 | from django.utils.safestring import mark_safe 4 | import time 5 | 6 | from wagtail_editorjs.features import ( 7 | WagtailBlockFeature, 8 | ) 9 | from wagtail_editorjs.render import render_editorjs_html 10 | from wagtail_editorjs.registry import ( 11 | EDITOR_JS_FEATURES, 12 | ) 13 | 14 | from .base import BaseEditorJSTest 15 | 16 | class SubBlock(blocks.StructBlock): 17 | sub_title = blocks.CharBlock() 18 | sub_text = blocks.CharBlock() 19 | 20 | class Meta: 21 | allowed_tags = ["h3", "p"] 22 | allowed_attributes = { 23 | "h3": ["class"], 24 | "p": ["class"], 25 | } 26 | 27 | class TestWagtailBlockFeatureBlock(blocks.StructBlock): 28 | title = blocks.CharBlock() 29 | subtitle = blocks.CharBlock() 30 | sub_block = SubBlock() 31 | 32 | class Meta: 33 | allowed_tags = ["h1", "h2"] 34 | allowed_attributes = { 35 | "h1": ["class"], 36 | "h2": ["class"], 37 | } 38 | 39 | def render(self, value, context=None): 40 | return f"

{value['title']}

{value['subtitle']}

{value['sub_block']['sub_title']}

{value['sub_block']['sub_text']}

" 41 | 42 | 43 | class TestWagtailBlockFeature(BaseEditorJSTest): 44 | 45 | def setUp(self) -> None: 46 | super().setUp() 47 | 48 | self.block = TestWagtailBlockFeatureBlock() 49 | self.feature = WagtailBlockFeature( 50 | "test_feature", 51 | block=self.block, 52 | ) 53 | 54 | EDITOR_JS_FEATURES.register( 55 | "test_feature", 56 | self.feature, 57 | ) 58 | 59 | def test_value_for_form(self): 60 | test_data = { 61 | "title": "Test Title", 62 | "subtitle": "Test Text", 63 | "sub_block": { 64 | "sub_title": "Sub Title", 65 | "sub_text": "Sub Text", 66 | }, 67 | } 68 | 69 | feature_value = { 70 | "type": "test_feature", 71 | "data": { 72 | "block": test_data, 73 | } 74 | } 75 | 76 | editorjs_value = { 77 | "time": int(time.time()), 78 | "blocks": [feature_value], 79 | "version": "0.0.0", 80 | } 81 | 82 | tdata_copy = test_data.copy() 83 | copied = editorjs_value.copy() 84 | 85 | data = EDITOR_JS_FEATURES.value_for_form( 86 | ["test_feature"], 87 | editorjs_value 88 | ) 89 | 90 | self.assertTrue(isinstance(data, dict)) 91 | self.assertIn("blocks", data) 92 | self.assertTrue(isinstance(data["blocks"], list)) 93 | self.assertTrue(len(data["blocks"]) == 1, msg=f"Expected 1 block, got {len(data['blocks'])}") 94 | 95 | self.assertDictEqual( 96 | data, 97 | copied | { 98 | "blocks": [ 99 | { 100 | "type": "test_feature", 101 | "data": { 102 | "block": tdata_copy, 103 | } 104 | } 105 | ] 106 | } 107 | ) 108 | 109 | 110 | def test_wagtail_block_feature(self): 111 | test_data = { 112 | "title": "Test Title", 113 | "subtitle": "Test Text", 114 | "sub_block": { 115 | "sub_title": "Sub Title", 116 | "sub_text": "Sub Text", 117 | }, 118 | } 119 | 120 | feature_value = { 121 | "type": "test_feature", 122 | "data": { 123 | "block": test_data, 124 | } 125 | } 126 | 127 | editorjs_value = { 128 | "time": int(time.time()), 129 | "blocks": [feature_value], 130 | "version": "0.0.0", 131 | } 132 | 133 | html = render_editorjs_html(features=["test_feature"], data=editorjs_value) 134 | feature_html = str(self.feature.render_block_data(feature_value)) 135 | 136 | 137 | self.assertHTMLEqual( 138 | html, 139 | render_to_string( 140 | "wagtail_editorjs/rich_text.html", 141 | {"html": mark_safe(feature_html)} 142 | ), 143 | ) 144 | 145 | self.assertInHTML( 146 | "

Test Title

", 147 | feature_html, 148 | ) 149 | 150 | self.assertInHTML( 151 | "

Test Text

", 152 | feature_html, 153 | ) 154 | 155 | self.assertInHTML( 156 | "

Sub Title

", 157 | feature_html, 158 | ) 159 | 160 | self.assertInHTML( 161 | "

Sub Text

", 162 | feature_html, 163 | ) 164 | 165 | -------------------------------------------------------------------------------- /wagtail_editorjs/static/wagtail_editorjs/vendor/editorjs/tools/quote.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Skipped minification because the original files appears to be already minified. 3 | * Original file: /npm/@editorjs/quote@2.6.0/dist/quote.umd.js 4 | * 5 | * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files 6 | */ 7 | (function(){"use strict";try{if(typeof document<"u"){var t=document.createElement("style");t.appendChild(document.createTextNode(".cdx-quote-icon svg{transform:rotate(180deg)}.cdx-quote{margin:0}.cdx-quote__text{min-height:158px;margin-bottom:10px}.cdx-quote [contentEditable=true][data-placeholder]:before{position:absolute;content:attr(data-placeholder);color:#707684;font-weight:400;opacity:0}.cdx-quote [contentEditable=true][data-placeholder]:empty:before{opacity:1}.cdx-quote [contentEditable=true][data-placeholder]:empty:focus:before{opacity:0}.cdx-quote-settings{display:flex}.cdx-quote-settings .cdx-settings-button{width:50%}")),document.head.appendChild(t)}}catch(e){console.error("vite-plugin-css-injected-by-js",e)}})(); 8 | (function(s,o){typeof exports=="object"&&typeof module<"u"?module.exports=o():typeof define=="function"&&define.amd?define(o):(s=typeof globalThis<"u"?globalThis:s||self,s.Quote=o())})(this,function(){"use strict";const s="",o='',c='',l='';class i{static get isReadOnlySupported(){return!0}static get toolbox(){return{icon:l,title:"Quote"}}static get contentless(){return!0}static get enableLineBreaks(){return!0}static get DEFAULT_QUOTE_PLACEHOLDER(){return"Enter a quote"}static get DEFAULT_CAPTION_PLACEHOLDER(){return"Enter a caption"}static get ALIGNMENTS(){return{left:"left",center:"center"}}static get DEFAULT_ALIGNMENT(){return i.ALIGNMENTS.left}static get conversionConfig(){return{import:"text",export:function(t){return t.caption?`${t.text} — ${t.caption}`:t.text}}}get CSS(){return{baseClass:this.api.styles.block,wrapper:"cdx-quote",text:"cdx-quote__text",input:this.api.styles.input,caption:"cdx-quote__caption"}}get settings(){return[{name:"left",icon:c},{name:"center",icon:o}]}constructor({data:t,config:e,api:n,readOnly:r}){const{ALIGNMENTS:a,DEFAULT_ALIGNMENT:d}=i;this.api=n,this.readOnly=r,this.quotePlaceholder=e.quotePlaceholder||i.DEFAULT_QUOTE_PLACEHOLDER,this.captionPlaceholder=e.captionPlaceholder||i.DEFAULT_CAPTION_PLACEHOLDER,this.data={text:t.text||"",caption:t.caption||"",alignment:Object.values(a).includes(t.alignment)&&t.alignment||e.defaultAlignment||d}}render(){const t=this._make("blockquote",[this.CSS.baseClass,this.CSS.wrapper]),e=this._make("div",[this.CSS.input,this.CSS.text],{contentEditable:!this.readOnly,innerHTML:this.data.text}),n=this._make("div",[this.CSS.input,this.CSS.caption],{contentEditable:!this.readOnly,innerHTML:this.data.caption});return e.dataset.placeholder=this.quotePlaceholder,n.dataset.placeholder=this.captionPlaceholder,t.appendChild(e),t.appendChild(n),t}save(t){const e=t.querySelector(`.${this.CSS.text}`),n=t.querySelector(`.${this.CSS.caption}`);return Object.assign(this.data,{text:e.innerHTML,caption:n.innerHTML})}static get sanitize(){return{text:{br:!0},caption:{br:!0},alignment:{}}}renderSettings(){const t=e=>e[0].toUpperCase()+e.substr(1);return this.settings.map(e=>({icon:e.icon,label:this.api.i18n.t(`Align ${t(e.name)}`),onActivate:()=>this._toggleTune(e.name),isActive:this.data.alignment===e.name,closeOnActivate:!0}))}_toggleTune(t){this.data.alignment=t}_make(t,e=null,n={}){const r=document.createElement(t);Array.isArray(e)?r.classList.add(...e):e&&r.classList.add(e);for(const a in n)r[a]=n[a];return r}}return i}); 9 | -------------------------------------------------------------------------------- /wagtail_editorjs/registry/element/element.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Union, TypeVar 2 | from .attrs import EditorJSElementAttribute 3 | from .utils import add_attributes, wrap_tag, _make_attr 4 | import bs4 5 | 6 | 7 | ElementType = TypeVar("ElementType", bound="EditorJSElement") 8 | 9 | 10 | class EditorJSElement: 11 | """ 12 | Base class for all elements. 13 | """ 14 | 15 | def __init__(self, tag: str, content: Union[str, list[str]] = None, attrs: dict[str, EditorJSElementAttribute] = None, close_tag: bool = True): 16 | attrs = attrs or {} 17 | content = content or [] 18 | 19 | if not isinstance(attrs, EditorJSElementAttribute): 20 | attrs = { 21 | key: _make_attr(value) 22 | for key, value in attrs.items() 23 | } 24 | 25 | if isinstance(content, str): 26 | content = [content] 27 | 28 | self.tag = tag 29 | self._content = content 30 | self.attrs = attrs 31 | self.close_tag = close_tag 32 | 33 | @property 34 | def is_wrapped(self): 35 | return False 36 | 37 | def __setitem__(self, key, value): 38 | if "key" in self.attrs: 39 | attrs = self.attrs[key] 40 | attrs.append(value) 41 | self.attrs[key] = attrs 42 | else: 43 | self.attrs[key] = _make_attr(value) 44 | 45 | def add_attributes(self, **attrs: Union[str, list[str], dict[str, Any]]): 46 | return add_attributes(self, **attrs) 47 | 48 | @property 49 | def content(self): 50 | if isinstance(self._content, list): 51 | return "\n".join([str(item) for item in self._content]) 52 | return str(self._content) 53 | 54 | @content.setter 55 | def content(self, value): 56 | if isinstance(value, list): 57 | self._content = value 58 | else: 59 | self._content = [value] 60 | 61 | def append(self, element: "EditorJSElement"): 62 | self._content.append(element) 63 | 64 | def __str__(self): 65 | return wrap_tag( 66 | self.tag, 67 | attrs = self.attrs, 68 | content = self.content, 69 | close_tag = self.close_tag 70 | ) 71 | 72 | 73 | class EditorJSSoupElement(EditorJSElement): 74 | def __init__(self, raw_html: str): 75 | self.raw_html = raw_html 76 | self.soup = bs4.BeautifulSoup(raw_html, "html.parser") 77 | self.soupContent = self.soup.contents[0] 78 | 79 | @property 80 | def content(self): 81 | return str(self.soup) 82 | 83 | @content.setter 84 | def content(self, value): 85 | self.soup = bs4.BeautifulSoup(value, "html.parser") 86 | self.soupContent = self.soup.contents[0] 87 | 88 | @property 89 | def attrs(self): 90 | return self.soupContent.attrs 91 | 92 | def __str__(self): 93 | return str(self.soup) 94 | 95 | def append(self, element: "EditorJSElement"): 96 | if isinstance(element, EditorJSSoupElement): 97 | self.soupContent.append(element.soup) 98 | elif isinstance(element, str): 99 | self.soupContent.append(element) 100 | elif isinstance(element, EditorJSElement): 101 | self.soupContent.append(str(element)) 102 | else: 103 | raise TypeError(f"Invalid type {type(element)}") 104 | 105 | def add_attributes(self, **attrs: Union[str, list[str], dict[str, Any]]): 106 | for key, value in attrs.items(): 107 | if key == "class_" or key == "class": 108 | classList = self.soupContent.get("class", []) 109 | if isinstance(value, str): 110 | classList.append(value) 111 | elif isinstance(value, list): 112 | classList.extend(value) 113 | self.soupContent["class"] = classList 114 | else: 115 | self.soupContent[key] = value 116 | 117 | 118 | def wrapper(element: EditorJSElement, attrs: dict[str, EditorJSElementAttribute] = None, tag: str = "div"): 119 | 120 | if isinstance(element, EditorJSWrapper) or getattr(element, "is_wrapped", False): 121 | return add_attributes(element, **attrs) 122 | 123 | return EditorJSWrapper(element, attrs=attrs, tag=tag) 124 | 125 | 126 | class EditorJSWrapper(EditorJSElement): 127 | @property 128 | def is_wrapped(self): 129 | return True 130 | 131 | @property 132 | def wrapped_element(self) -> Union[ElementType, list[ElementType]]: 133 | if len(self._content) > 1: 134 | return self._content 135 | 136 | return self._content[0] 137 | 138 | def __init__(self, 139 | content: Union[ElementType, list[ElementType]], 140 | attrs: dict[str, EditorJSElementAttribute] = None, 141 | close_tag: bool = True, 142 | tag: str = "div", 143 | ): 144 | 145 | if not isinstance(content, (list, tuple)): 146 | content = [content] 147 | 148 | for item in content: 149 | 150 | item_type = type(item) 151 | if item_type == EditorJSWrapper: 152 | raise ValueError( 153 | "Cannot nest EditorJSWrapper elements\n" 154 | "Please check if the element is already wrapped before re-wrapping.\n" 155 | ) 156 | 157 | if not issubclass(item_type, EditorJSElement): 158 | raise ValueError(f"Expected EditorJSElement got {type(item)}") 159 | 160 | super().__init__(tag, content, attrs, close_tag) 161 | -------------------------------------------------------------------------------- /wagtail_editorjs/features/lists.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from django import forms 3 | from django.utils.translation import gettext_lazy as _ 4 | from ..registry import ( 5 | EditorJSFeature, 6 | EditorJSBlock, 7 | EditorJSElement, 8 | wrap_tag, 9 | ) 10 | 11 | 12 | class NestedListElement(EditorJSElement): 13 | def __init__(self, tag: str, items: list[EditorJSElement], close_tag: bool = True, attrs: dict[str, Any] = None): 14 | super().__init__(tag=tag, content=None, close_tag=close_tag, attrs=attrs) 15 | self.items = items 16 | 17 | def __str__(self): 18 | return wrap_tag(self.tag, self.attrs, "".join([str(item) for item in self.items]), self.close_tag) 19 | 20 | @property 21 | def content(self): 22 | return "".join([str(item) for item in self.items]) 23 | 24 | @content.setter 25 | def content(self, value): 26 | if isinstance(value, list): 27 | self.items = value 28 | else: 29 | self.items = [value] 30 | 31 | def append(self, item: "NestedListElement"): 32 | self.items.append(item) 33 | 34 | 35 | def parse_list(items: list[dict[str, Any]], element: str, depth = 0) -> NestedListElement: 36 | s = [] 37 | 38 | for item in items: 39 | content = item.get("content") 40 | items = item.get("items") 41 | s.append(f"
  • {content}") 42 | if items: 43 | s.append(parse_list(items, element, depth + 1)) 44 | s.append(f"
  • ") 45 | 46 | return NestedListElement(element, s, attrs={"class": "nested-list", "style": f"--depth: {depth}"}) 47 | 48 | class NestedListFeature(EditorJSFeature): 49 | allowed_tags = ["ul", "ol", "li"] 50 | allowed_attributes = ["class", "style"] 51 | klass="NestedList" 52 | js = [ 53 | "wagtail_editorjs/vendor/editorjs/tools/nested-list.js", 54 | ] 55 | 56 | def validate(self, data: Any): 57 | super().validate(data) 58 | 59 | items = data["data"].get("items") 60 | if not items: 61 | raise forms.ValidationError("Invalid items value") 62 | 63 | if "style" not in data["data"]: 64 | raise forms.ValidationError("Invalid style value") 65 | 66 | if data["data"]["style"] not in ["ordered", "unordered"]: 67 | raise forms.ValidationError("Invalid style value") 68 | 69 | def render_block_data(self, block: EditorJSBlock, context = None) -> EditorJSElement: 70 | element = "ol" if block["data"]["style"] == "ordered" else "ul" 71 | return parse_list(block["data"]["items"], element) 72 | 73 | @classmethod 74 | def get_test_data(cls): 75 | return [ 76 | { 77 | "style": "unordered", 78 | "items": [ 79 | { 80 | "content": "Item 1", 81 | "items": [ 82 | { 83 | "content": "Item 1.1", 84 | "items": [ 85 | { 86 | "content": "Item 1.1.1", 87 | "items": [], 88 | }, 89 | { 90 | "content": "Item 1.1.2", 91 | "items": [], 92 | }, 93 | ], 94 | }, 95 | { 96 | "content": "Item 1.2", 97 | "items": [], 98 | }, 99 | ], 100 | }, 101 | { 102 | "content": "Item 2", 103 | "items": [], 104 | }, 105 | ], 106 | }, 107 | ] 108 | 109 | 110 | class CheckListFeature(EditorJSFeature): 111 | allowed_tags = ["ul", "li"] 112 | allowed_attributes = ["class"] 113 | klass="Checklist" 114 | js=[ 115 | "wagtail_editorjs/vendor/editorjs/tools/checklist.js", 116 | ] 117 | 118 | def validate(self, data: Any): 119 | super().validate(data) 120 | 121 | items = data["data"].get("items") 122 | if not items: 123 | raise forms.ValidationError("Invalid items value") 124 | 125 | for item in items: 126 | if "checked" not in item: 127 | raise forms.ValidationError("Invalid checked value") 128 | 129 | if "text" not in item: 130 | raise forms.ValidationError("Invalid text value") 131 | 132 | def render_block_data(self, block: EditorJSBlock, context = None) -> EditorJSElement: 133 | s = [] 134 | for item in block["data"]["items"]: 135 | class_ = "checklist-item" 136 | if item["checked"]: 137 | class_ += " checked" 138 | 139 | s.append(wrap_tag("li", {"class": class_}, item["text"])) 140 | 141 | return EditorJSElement("ul", "".join(s), attrs={"class": "checklist"}) 142 | 143 | @classmethod 144 | def get_test_data(cls): 145 | return [ 146 | { 147 | "items": [ 148 | { 149 | "checked": True, 150 | "text": "Item 1", 151 | }, 152 | { 153 | "checked": False, 154 | "text": "Item 2", 155 | }, 156 | ], 157 | } 158 | ] 159 | 160 | -------------------------------------------------------------------------------- /wagtail_editorjs/render.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Union 2 | from collections import defaultdict 3 | from django.template.loader import render_to_string 4 | from django.template.context import Context 5 | from django.utils.safestring import mark_safe 6 | from . import settings 7 | from .registry import ( 8 | EditorJSElement, 9 | InlineEditorJSFeature, 10 | EDITOR_JS_FEATURES, 11 | ) 12 | import bleach, bs4 13 | 14 | 15 | class NullSanitizer: 16 | @staticmethod 17 | def sanitize_css(val): 18 | return val 19 | 20 | def render_editorjs_html( 21 | features: list[str], 22 | data: dict, 23 | context=None, 24 | clean: bool = None, 25 | whitelist_tags: list[str] = None, 26 | whitelist_attrs: Union[dict, list] = None 27 | ) -> str: 28 | """ 29 | Renders the editorjs widget based on the features provided. 30 | """ 31 | 32 | if "blocks" not in data: 33 | data["blocks"] = [] 34 | 35 | feature_mappings = { 36 | feature: EDITOR_JS_FEATURES[feature] 37 | for feature in features 38 | } 39 | 40 | inlines = [ 41 | feature 42 | for feature in feature_mappings.values() 43 | if isinstance(feature, InlineEditorJSFeature) 44 | ] 45 | 46 | html = [] 47 | for block in data["blocks"]: 48 | 49 | feature: str = block["type"] 50 | tunes: dict[str, Any] = block.get("tunes", {}) 51 | feature_mapping = feature_mappings.get(feature, None) 52 | 53 | if not feature_mapping: 54 | continue 55 | 56 | # Build the actual block. 57 | element: EditorJSElement = feature_mapping.render_block_data(block, context) 58 | 59 | # Optionally tools can decide to not render the block. 60 | if element is None: 61 | continue 62 | 63 | # Tune the element. 64 | for tune_name, tune_value in tunes.items(): 65 | if tune_name not in feature_mappings: 66 | continue 67 | 68 | element = feature_mappings[tune_name].tune_element(element, tune_value, context) 69 | 70 | # Add the block ID to each individual block. 71 | if settings.ADD_BLOCK_ID: 72 | # This can be used to link frontend to the admin area. 73 | element.attrs[settings.BLOCK_ID_ATTR] = block.get("id", "") 74 | 75 | html.append(element) 76 | 77 | html = "\n".join([str(h) for h in html]) 78 | 79 | soup = bs4.BeautifulSoup(html, "html.parser") 80 | if inlines: 81 | for inline in inlines: 82 | # Give inlines access to whole soup. 83 | # This allows for proper parsing of say; page or document links. 84 | inline: InlineEditorJSFeature 85 | inline.parse_inline_data(soup, context) 86 | 87 | # Re-render the soup. 88 | html = soup.decode(False) 89 | 90 | if clean or (clean is None and settings.CLEAN_HTML): 91 | allowed_tags = set({ 92 | # Default inline tags. 93 | "i", "b", "strong", "em", "u", "s", "strike" 94 | }) 95 | allowed_attributes = defaultdict(set) 96 | # cleaner_funcs = defaultdict(lambda: defaultdict(list)) 97 | 98 | for feature in feature_mappings.values(): 99 | allowed_tags.update(feature.allowed_tags) 100 | # for key, value in feature.cleaner_funcs.items(): 101 | # for name, func in value.items(): 102 | # cleaner_funcs[key][name].append(func) 103 | 104 | for key, value in feature.allowed_attributes.items(): 105 | allowed_attributes[key].update(value) 106 | 107 | if whitelist_tags: 108 | allowed_tags.update(whitelist_tags) 109 | 110 | if "*" in allowed_attributes: 111 | allowed_attributes["*"].add(settings.BLOCK_ID_ATTR) 112 | else: 113 | allowed_attributes["*"] = {settings.BLOCK_ID_ATTR} 114 | 115 | if whitelist_attrs: 116 | if isinstance(whitelist_attrs, dict): 117 | for key, value in whitelist_attrs.items(): 118 | allowed_attributes[key].update(value) 119 | else: 120 | for key in allowed_attributes: 121 | allowed_attributes[key].update(whitelist_attrs) 122 | 123 | html = bleach.clean( 124 | html, 125 | tags=allowed_tags, 126 | attributes=allowed_attributes, 127 | css_sanitizer=NullSanitizer, 128 | ) 129 | 130 | ctx = context or {} 131 | ctx["html"] = html 132 | 133 | if isinstance(context, Context): 134 | ctx = context.flatten() 135 | 136 | return render_to_string( 137 | "wagtail_editorjs/rich_text.html", 138 | context=ctx, 139 | request=ctx.get("request", None) 140 | ) 141 | 142 | 143 | 144 | # def parse_allowed_attributes(tag, name, value): 145 | # if ( 146 | # tag not in allowed_attributes\ 147 | # and tag not in cleaner_funcs\ 148 | # and "*" not in cleaner_funcs\ 149 | # and "*" not in allowed_attributes 150 | # ): 151 | # return False 152 | # 153 | # if "*" in cleaner_funcs and name in cleaner_funcs["*"] and any( 154 | # func(value) for func in cleaner_funcs["*"][name] 155 | # ): 156 | # return True 157 | # 158 | # if tag in cleaner_funcs\ 159 | # and name in cleaner_funcs[tag]\ 160 | # and any( 161 | # func(value) for func in cleaner_funcs[tag][name] 162 | # ): 163 | # return True 164 | # 165 | # if name in allowed_attributes[tag] or name in allowed_attributes["*"]: 166 | # return True 167 | # 168 | # return False 169 | -------------------------------------------------------------------------------- /wagtail_editorjs/static/wagtail_editorjs/js/tools/wagtail-button-tool.js: -------------------------------------------------------------------------------- 1 | const wagtailButtonIcon = ` 2 | 3 | 4 | 5 | 6 | `; 7 | 8 | const wagtailButtonEditIcon = ` 9 | 10 | 11 | 12 | 13 | `; 14 | 15 | 16 | class PageButtonTool extends window.BaseWagtailEditorJSTool { 17 | constructor({ data, api, config, block }) { 18 | super({ data, api, config, block }); 19 | 20 | this.settings = [ 21 | new window.BaseButtonSetting({ 22 | icon: wagtailButtonIcon, 23 | name: 'change-url', 24 | description: this.api.i18n.t('Change URL'), 25 | action: () => { 26 | window.openChooserModal( 27 | this.pageChooser, this.setData.bind(this), 28 | ) 29 | }, 30 | }), 31 | ]; 32 | this.initSettings(); 33 | this.pageChooser = this.newChooser(); 34 | } 35 | 36 | setData(data) { 37 | this.wrapperElement.dataset.url = data.url; 38 | this.wrapperElement.dataset.pageId = data.id; 39 | this.wrapperElement.dataset.parentPageId = data.parentId; 40 | this.buttonLinkElement.href = data.url; 41 | this.buttonElement.innerText = data.title; 42 | } 43 | 44 | 45 | newChooser() { 46 | let urlParams = { 47 | page_type: this.config.page_type || 'wagtailcore.page', 48 | allow_external_link: this.config.allow_external_link || true, 49 | allow_email_link: this.config.allow_email_link || true, 50 | allow_phone_link: this.config.allow_phone_link || true, 51 | allow_anchor_link: this.config.allow_anchor_link || true, 52 | }; 53 | 54 | const cfg = { 55 | url: this.config.chooserUrls.pageChooser, 56 | urlParams: urlParams, 57 | onload: window.PAGE_CHOOSER_MODAL_ONLOAD_HANDLERS, 58 | modelNames: ['wagtailcore.page'], 59 | }; 60 | 61 | return new window.PageChooser(this.config.chooserId, cfg); 62 | } 63 | 64 | static get toolbox() { 65 | return { 66 | title: 'Page Button', 67 | icon: wagtailButtonIcon, 68 | }; 69 | } 70 | 71 | render() { 72 | this.wrapperElement = window.makeElement('div', { 73 | className: 'wagtail-button-wrapper button button-secondary', 74 | }); 75 | 76 | this.buttonElement = window.makeElement('div', { 77 | className: 'wagtail-button', 78 | contentEditable: true, 79 | }); 80 | 81 | this.buttonLinkElement = window.makeElement('a', { 82 | "innerHTML": wagtailButtonIcon, 83 | "className": "wagtail-button-icon", 84 | "target": "_blank", 85 | }); 86 | 87 | this.chooseNewPageButton = window.makeElement('button', { 88 | "innerHTML": wagtailButtonEditIcon, 89 | "className": "wagtail-button-icon wagtail-button-edit", 90 | }); 91 | 92 | this.wrapperElement.appendChild(this.buttonElement); 93 | this.wrapperElement.appendChild(this.buttonLinkElement); 94 | this.wrapperElement.appendChild(this.chooseNewPageButton); 95 | 96 | if (this.data && this.data.url) { 97 | this.wrapperElement.dataset.url = this.data.url; 98 | this.wrapperElement.dataset.pageId = this.data.pageId; 99 | this.wrapperElement.dataset.parentPageId = this.data.parentId; 100 | this.buttonLinkElement.href = this.data.url; 101 | this.buttonElement.innerText = this.data.text; 102 | } else { 103 | window.openChooserModal(this.pageChooser, this.setData.bind(this)); 104 | } 105 | 106 | this.chooseNewPageButton.addEventListener('click', () => { 107 | window.openChooserModal(this.pageChooser, this.setData.bind(this)); 108 | }); 109 | 110 | return super.render(); 111 | } 112 | 113 | validate(savedData) { 114 | if (!("pageId" in savedData) || !("text" in savedData)) { 115 | return false; 116 | } 117 | 118 | return true; 119 | } 120 | 121 | save(blockContent) { 122 | this.data = super.save(blockContent); 123 | this.data.text = this.buttonElement.innerText; 124 | this.data.url = this.wrapperElement.dataset.url; 125 | this.data.pageId = this.wrapperElement.dataset.pageId; 126 | this.data.parentId = this.wrapperElement.dataset.parentPageId; 127 | return this.data; 128 | } 129 | } 130 | 131 | window.PageButtonTool = PageButtonTool; -------------------------------------------------------------------------------- /push-to-github.ps1: -------------------------------------------------------------------------------- 1 | param ( 2 | [string]$CommitMessage = "Update to package", 3 | [bool]$Tag = $false, 4 | [string]$TagName = "0.0.0" 5 | ) 6 | 7 | if ($TagName -ne "0.0.0") { 8 | $Tag = $true 9 | } 10 | 11 | $ProjectName = "wagtail_editorjs" 12 | 13 | 14 | function IsNumeric ($Value) { 15 | return $Value -match "^[\d\.]+$" 16 | } 17 | 18 | Function GITHUB_Upload { 19 | param ( 20 | [parameter(Mandatory=$false)] 21 | [string]$Version 22 | ) 23 | 24 | git add . 25 | if ($Tag) { 26 | $gitVersion = "v${Version}" 27 | git commit -m $CommitMessage 28 | git tag $gitVersion 29 | git push -u origin main --tags 30 | } else { 31 | git commit -m $CommitMessage 32 | git push -u origin main 33 | } 34 | } 35 | 36 | Function _NextVersionString { 37 | param ( 38 | [string]$Version 39 | ) 40 | 41 | $versionParts = $version -split "\." 42 | 43 | $major = [int]$versionParts[0] 44 | $minor = [int]$versionParts[1] 45 | $patch = [int]$versionParts[2] + 1 46 | 47 | # validate integers 48 | if (-not (IsNumeric $major) -or -not (IsNumeric $minor) -or -not (IsNumeric $patch)) { 49 | Write-Host "Invalid version format" 50 | throw "Invalid version format" 51 | } 52 | 53 | if ($patch -gt 9) { 54 | $patch = 0 55 | $minor += 1 56 | } 57 | 58 | if ($minor -gt 9) { 59 | $minor = 0 60 | $major += 1 61 | } 62 | 63 | $newVersion = "$major.$minor.$patch" 64 | 65 | return $newVersion 66 | } 67 | 68 | function PYPI_NextVersion { 69 | param ( 70 | [string]$ConfigFile = ".\setup.cfg" 71 | ) 72 | # Read file content 73 | $fileContent = Get-Content -Path $ConfigFile 74 | 75 | # Extract the version, increment it, and prepare the updated version string 76 | $versionLine = $fileContent | Where-Object { $_ -match "version\s*=" } 77 | $version = $versionLine -split "=", 2 | ForEach-Object { $_.Trim() } | Select-Object -Last 1 78 | $newVersion = _NextVersionString -Version $version 79 | return $newVersion 80 | } 81 | 82 | function InitRepo { 83 | param ( 84 | [string]$ConfigFile = ".\setup.cfg" 85 | ) 86 | Write-Host "Initialising repository..." 87 | git init | Out-Host 88 | git add . | Out-Host 89 | git branch -M main | Out-Host 90 | git remote add origin "git@github.com:Nigel2392/${ProjectName}.git" | Out-Host 91 | $version = PYPI_NextVersion -ConfigFile $ConfigFile 92 | Write-Host "Initial version: $version" 93 | return $version 94 | } 95 | 96 | function GITHUB_NextVersion { 97 | param ( 98 | [string]$ConfigFile = ".\setup.cfg", 99 | [string]$PyVersionFile = ".\${ProjectName}\__init__.py" 100 | ) 101 | 102 | 103 | # Extract the version, increment it, and prepare the updated version string 104 | $version = "$(git tag -l --format='VERSION=%(refname:short)' | Sort-Object -Descending | Select-Object -First 1)" -split "=v", 2 | ForEach-Object { $_.Trim() } | Select-Object -Last 1 105 | 106 | if ($version -And $TagName -eq "0.0.0") { 107 | $newVersion = _NextVersionString -Version $version 108 | Write-Host "Next version (git): $newVersion" 109 | return $newVersion 110 | } else { 111 | if ($TagName -ne "0.0.0") { 112 | # $TagName = $version 113 | # $TagName = _NextVersionString -Version $TagName 114 | Write-Host "Next version (tag): $TagName" 115 | return $TagName 116 | } 117 | $newVersion = InitRepo -ConfigFile $ConfigFile 118 | Write-Host "Next version (init): $newVersion" 119 | return $newVersion 120 | } 121 | } 122 | 123 | Function GITHUB_UpdateVersion { 124 | param ( 125 | [string]$ConfigFile = ".\setup.cfg", 126 | [string]$PyVersionFile = ".\${ProjectName}\__init__.py" 127 | ) 128 | 129 | $newVersion = GITHUB_NextVersion -ConfigFile $ConfigFile 130 | 131 | Write-Host "Updating version to $newVersion" 132 | 133 | # First update the init file so that in case something goes wrong 134 | # the version doesn't persist in the config file 135 | if (Test-Path $PyVersionFile) { 136 | $initContent = Get-Content -Path $PyVersionFile 137 | $initContent = $initContent -replace "__version__\s*=\s*.+", "__version__ = '$newVersion'" 138 | Set-Content -Path $PyVersionFile -Value $initContent 139 | } 140 | 141 | # Read file content 142 | $fileContent = Get-Content -Path $ConfigFile 143 | 144 | if (Test-Path $ConfigFile) { 145 | # Update the version line in the file content 146 | $updatedContent = $fileContent -replace "version\s*=\s*.+", "version = $newVersion" 147 | 148 | # Write the updated content back to the file 149 | Set-Content -Path $ConfigFile -Value $updatedContent 150 | } 151 | 152 | return $newVersion 153 | } 154 | 155 | 156 | Function _PYPI_DistName { 157 | param ( 158 | [string]$Version, 159 | [string]$Append = ".tar.gz" 160 | ) 161 | 162 | return "$ProjectName-$Version$Append" 163 | } 164 | 165 | Function PYPI_Build { 166 | py .\setup.py sdist 167 | } 168 | 169 | Function PYPI_Check { 170 | param ( 171 | [string]$Version 172 | ) 173 | 174 | $distFile = _PYPI_DistName -Version $Version 175 | py -m twine check "./dist/${distFile}" 176 | } 177 | 178 | Function PYPI_Upload { 179 | param ( 180 | [string]$Version 181 | ) 182 | 183 | $distFile = _PYPI_DistName -Version $Version 184 | python3 -m twine upload "./dist/${distFile}" 185 | } 186 | 187 | if ($Tag) { 188 | $version = GITHUB_UpdateVersion # Increment the package version (setup.cfg) 189 | GITHUB_Upload -Version $version # Upload the package (git push) 190 | PYPI_Build # Build the package (python setup.py sdist) 191 | PYPI_Check -Version $version # Check the package (twine check dist/) 192 | PYPI_Upload -Version $version # Upload the package (twine upload dist/) 193 | } else { 194 | GITHUB_Upload # Upload the package 195 | } 196 | 197 | 198 | 199 | 200 | -------------------------------------------------------------------------------- /wagtail_editorjs/test/core/tests/test_render.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from django.template.loader import render_to_string 3 | from django.utils.safestring import mark_safe 4 | from bs4 import BeautifulSoup 5 | 6 | from .base import BaseEditorJSTest 7 | from wagtail_editorjs.render import render_editorjs_html 8 | from wagtail_editorjs.registry import ( 9 | EditorJSTune, 10 | EditorJSFeature, 11 | EditorJSElement, 12 | EDITOR_JS_FEATURES, 13 | ) 14 | 15 | 16 | class TestEditorJSTune(EditorJSTune): 17 | allowed_attributes = { 18 | "*": ["data-testing-id"], 19 | } 20 | klass = 1 21 | 22 | def tune_element(self, element: EditorJSElement, tune_value: Any, context=None) -> EditorJSElement: 23 | element.attrs["data-testing-id"] = tune_value 24 | return element 25 | 26 | 27 | # Create your tests here. 28 | class TestEditorJSFeatures(BaseEditorJSTest): 29 | 30 | def setUp(self) -> None: 31 | super().setUp() 32 | self.tune = TestEditorJSTune( 33 | "test_tune_feature", 34 | None 35 | ) 36 | EDITOR_JS_FEATURES.register( 37 | "test_tune_feature", 38 | self.tune, 39 | ) 40 | 41 | def test_editorjs_features(self): 42 | 43 | html = [] 44 | test_data = [] 45 | for i, feature in enumerate(EDITOR_JS_FEATURES.features.values()): 46 | test_data_list = feature.get_test_data() 47 | if not isinstance(feature, (EditorJSFeature))\ 48 | or not test_data_list: 49 | continue 50 | 51 | for j, data in enumerate(test_data_list): 52 | test_data_list[j] = { 53 | "id": "test_id_{}_{}".format(i, j), 54 | "type": feature.tool_name, 55 | "data": data, 56 | "tunes": { 57 | "test_tune_feature": "test_id_{}_{}_tune".format(i, j) 58 | } 59 | } 60 | 61 | test_data.extend(test_data_list) 62 | 63 | for data in test_data_list: 64 | if hasattr(feature, "render_block_data"): 65 | tpl = feature.render_block_data(data) 66 | tpl = self.tune.tune_element(tpl, data["tunes"]["test_tune_feature"]) 67 | html.append(tpl) 68 | 69 | rendered_1 = render_editorjs_html( 70 | EDITOR_JS_FEATURES.keys(), 71 | {"blocks": test_data}, 72 | clean=False, 73 | ) 74 | 75 | rendered_2 = render_to_string( 76 | "wagtail_editorjs/rich_text.html", 77 | {"html": mark_safe("\n".join([str(h) for h in html]))} 78 | ) 79 | 80 | soup1 = BeautifulSoup(rendered_1, "html.parser") 81 | soup2 = BeautifulSoup(rendered_2, "html.parser") 82 | 83 | d1 = soup1.decode(False) 84 | d2 = soup2.decode(False) 85 | self.assertHTMLEqual( 86 | d1, d2, 87 | msg=( 88 | f"The rendered HTML for feature {feature} does not match the expected output.\n" 89 | "This might be due to a change in the rendering process.\n\n" 90 | "Expected: {expected}\n\n" 91 | "Got: {got}" % { 92 | "expected": d1, 93 | "got": d2, 94 | } 95 | ) 96 | ) 97 | 98 | def test_cleaned_editorjs_features(self): 99 | 100 | html = [] 101 | test_data = [] 102 | for i, feature in enumerate(EDITOR_JS_FEATURES.features.values()): 103 | test_data_list = feature.get_test_data() 104 | if not isinstance(feature, (EditorJSFeature))\ 105 | or not test_data_list: 106 | continue 107 | 108 | for j, data in enumerate(test_data_list): 109 | test_data_list[j] = { 110 | "id": "test_id_{}_{}".format(i, j), 111 | "type": feature.tool_name, 112 | "data": data, 113 | "tunes": { 114 | "test_tune_feature": "test_id_{}_{}_tune".format(i, j) 115 | } 116 | } 117 | 118 | for data in test_data_list: 119 | if hasattr(feature, "render_block_data"): 120 | tpl = feature.render_block_data(data) 121 | tpl = self.tune.tune_element(tpl, data["tunes"]["test_tune_feature"]) 122 | html.append(tpl) 123 | 124 | test_data.extend(test_data_list) 125 | 126 | rendered = render_editorjs_html( 127 | EDITOR_JS_FEATURES.keys(), 128 | {"blocks": test_data}, 129 | clean=True, 130 | ) 131 | 132 | soup = BeautifulSoup(rendered, "html.parser") 133 | 134 | for i, data in enumerate(test_data): 135 | block = soup.find(attrs={"data-testing-id": data["tunes"]["test_tune_feature"]}) 136 | 137 | if not block: 138 | self.fail( 139 | f"Block with id {data['tunes']['test_tune_feature']} not found.\n" 140 | "The tune might not have been properly applied. Check the test data.\n\n" 141 | f"Test data: {data}\n\n" 142 | f"Soup: {soup}" 143 | ) 144 | 145 | feature = EDITOR_JS_FEATURES[data["type"]] 146 | element = feature.render_block_data(data) 147 | element = self.tune.tune_element(element, data["tunes"]["test_tune_feature"]) 148 | 149 | soup_element = BeautifulSoup(str(element), "html.parser") 150 | 151 | self.assertHTMLEqual( 152 | str(block).replace("\n", "").strip(), str(soup_element).replace("\n", "").strip(), 153 | msg=( 154 | f"Block with feature {feature} ({i}) does not match the expected output.\n" 155 | "Something has gone wrong with the cleaning process.\n\n" 156 | "Expected: {expected}\n\n" 157 | "Got: {got}" % { 158 | "feature": data['tunes']['test_tune_feature'], 159 | "expected": str(soup_element).replace('\n', '').strip(), 160 | "got": str(block).replace('\n', '').strip(), 161 | } 162 | ) 163 | ) 164 | 165 | 166 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | wagtail_editorjs 2 | ================ 3 | 4 | *Check out [Awesome Wagtail](https://github.com/springload/awesome-wagtail) for more awesome packages and resources from the Wagtail community.* 5 | 6 | A Wagtail EditorJS widget with page/image chooser support, document support and more! 7 | 8 | ## Add features 9 | 10 | * [Add an EditorJS feature](https://github.com/Nigel2392/wagtail_editorjs/blob/main/docs/editorjs_feature.md "Simple Image Feature") 11 | * [Add an EditorJS tune](https://github.com/Nigel2392/wagtail_editorjs/blob/main/docs/tunes.md "text-alignment-tune") (Already exists in `wagtail_editorjs`, just an example.) 12 | 13 | Quick start 14 | ----------- 15 | 16 | 1. Add 'wagtail_editorjs' to your INSTALLED_APPS setting like this: 17 | 18 | ``` 19 | INSTALLED_APPS = [ 20 | ..., 21 | 'wagtail_editorjs', 22 | ] 23 | ``` 24 | 25 | 2. Add the HTML to your template: 26 | 27 | ```django-html 28 | 29 | {% load editorjs %} 30 | 31 | {# CSS files for features #} 32 | {% editorjs_static "css" %} 33 | 34 | {% editorjs self.editor_field %} 35 | 36 | {# JS files for features #} 37 | {% editorjs_static "js" %} 38 | ``` 39 | 40 | 3. Add the field to your model: 41 | 42 | ```python 43 | ... 44 | from wagtail_editorjs.fields import EditorJSField 45 | from wagtail_editorjs.blocks import EditorJSBlock 46 | 47 | 48 | class HomePage(Page): 49 | content_panels = [ 50 | FieldPanel("editor_field"), 51 | FieldPanel("content"), 52 | ] 53 | editor_field = EditorJSField( 54 | # All supported features 55 | features=[ 56 | 'attaches', 57 | 'background-color-tune', 58 | 'button', 59 | 'checklist', 60 | 'code', 61 | 'delimiter', 62 | 'document', 63 | 'drag-drop', 64 | 'header', 65 | 'image', 66 | 'images', 67 | 'inline-code', 68 | 'link', 69 | 'link-autocomplete', 70 | 'marker', 71 | 'nested-list', 72 | 'paragraph', 73 | 'quote', 74 | 'raw', 75 | 'table', 76 | 'text-alignment-tune', 77 | 'text-color-tune', 78 | 'text-variant-tune', 79 | 'tooltip', 80 | 'underline', 81 | 'undo-redo', 82 | 'warning' 83 | ], 84 | blank=True, 85 | null=True, 86 | ) 87 | 88 | # Or as a block 89 | content = fields.StreamField([ 90 | ('editorjs', EditorJSBlock(features=[ 91 | # ... same as before 92 | ])), 93 | ], blank=True, use_json_field=True) 94 | ``` 95 | 96 | ## List features 97 | 98 | This readme might not fully reflect which features are available. 99 | 100 | To find this out - you can: 101 | 102 | 1. start the python shell 103 | 104 | ```bash 105 | py ./manage.py shell 106 | ``` 107 | 108 | 2. Print all the available features: 109 | 110 | ```python 111 | from wagtail_editorjs.registry import EDITOR_JS_FEATURES 112 | print(EDITOR_JS_FEATURES.keys()) 113 | dict_keys([... all registered features ...]) 114 | ``` 115 | 116 | ## Register a Wagtail block as a feature 117 | 118 | **Warning, this is not available after wagtail 6.2 due to validation errors, TODO: fix this** 119 | 120 | It is also possible to register a Wagtail block as a feature. 121 | 122 | It is important to note that the block must be a `StructBlock` or a subclass of `StructBlock`. 123 | 124 | It is **not** allowed to be or include: 125 | 126 | * A `StreamBlock` (mainly due to styling issues) 127 | * A `ListBlock` (mainly due to styling issues) 128 | * A `RichTextBlock` (cannot initialize) 129 | 130 | *Help with these issues is highly appreciated!* 131 | 132 | Example: 133 | 134 | ```python 135 | from wagtail import hooks 136 | from wagtail_editorjs.features import ( 137 | WagtailBlockFeature, 138 | EditorJSFeatureStructBlock, 139 | ) 140 | from wagtail_editorjs.registry import ( 141 | EditorJSFeatures, 142 | ) 143 | from wagtail_editorjs.hooks import REGISTER_HOOK_NAME 144 | 145 | from wagtail import blocks 146 | 147 | class HeadingBlock(blocks.StructBlock): 148 | title = blocks.CharBlock() 149 | subtitle = blocks.CharBlock() 150 | 151 | class TextBlock(EditorJSFeatureStructBlock): 152 | heading = HeadingBlock() 153 | body = blocks.TextBlock() 154 | 155 | class Meta: 156 | template = "myapp/text_block.html" 157 | allowed_tags = ["h1", "h2", "p"] 158 | # Html looks like: 159 | #

    {{ self.heading.title }}

    160 | #

    {{ self.heading.subtitle }}

    161 | #

    {{ self.body }}

    162 | 163 | @hooks.register(REGISTER_HOOK_NAME) 164 | def register_editor_js_features(registry: EditorJSFeatures): 165 | 166 | registry.register( 167 | "wagtail-text-block", 168 | WagtailBlockFeature( 169 | "wagtail-text-block", 170 | block=TextBlock(), 171 | ), 172 | ) 173 | ``` 174 | 175 | The block will then be rendered as any structblock, but it will be wrapped in a div with the class `wagtail-text-block` (the feature name). 176 | 177 | Example: 178 | 179 | ```html 180 |
    181 |

    My title

    182 |

    My subtitle

    183 |

    My body

    184 |
    185 | ``` 186 | 187 | ## Settings 188 | 189 | ### `EDITORJS_CLEAN_HTML` 190 | 191 | Default: `True` 192 | Clean the HTML output on rendering. 193 | This happens every time the field is rendered. 194 | It might be smart to set up some sort of caching mechanism. 195 | Optionally; cleaning can be FORCED by passing `clean=True` or `False` to the `render_editorjs_html` function. 196 | 197 | ### `EDITORJS_ADD_BLOCK_ID` 198 | 199 | Default: `true` 200 | Add a block ID to each editorJS block when rendering. 201 | This is useful for targeting the block with JavaScript, 202 | or possibly creating some link from frontend to admin area. 203 | 204 | ### `EDITORJS_BLOCK_ID_ATTR` 205 | 206 | Default: `data-editorjs-block-id` 207 | The attribute name to use for the block ID. 208 | This is only used if `ADD_BLOCK_ID` is True. 209 | 210 | ### `EDITORJS_USE_FULL_URLS` 211 | 212 | Default: `False` 213 | Use full urls if the request is available in the EditorJS rendering context. 214 | -------------------------------------------------------------------------------- /wagtail_editorjs/static/wagtail_editorjs/js/tools/wagtail-link.js: -------------------------------------------------------------------------------- 1 | const wagtailLinkIcon = ` 2 | 3 | 4 | 5 | 6 | `; 7 | 8 | 9 | 10 | class WagtailLinkTool extends window.BaseWagtailChooserTool { 11 | constructor({ api, config }) { 12 | super({ api, config }) 13 | this.colorPicker = null; 14 | } 15 | 16 | get iconHTML() { 17 | return wagtailLinkIcon; 18 | } 19 | 20 | static get chooserType() { 21 | return 'page'; 22 | } 23 | 24 | newChooser() { 25 | let urlParams = { 26 | page_type: this.config.page_type || 'wagtailcore.page', 27 | allow_external_link: this.config.allow_external_link || true, 28 | allow_email_link: this.config.allow_email_link || true, 29 | allow_phone_link: this.config.allow_phone_link || true, 30 | allow_anchor_link: this.config.allow_anchor_link || true, 31 | }; 32 | 33 | const cfg = { 34 | url: this.config.chooserUrls.pageChooser, 35 | urlParams: urlParams, 36 | onload: window.PAGE_CHOOSER_MODAL_ONLOAD_HANDLERS, 37 | modelNames: ['wagtailcore.page'], 38 | }; 39 | 40 | return new window.PageChooser(this.config.chooserId, cfg); 41 | } 42 | 43 | showActions(wrapperTag) { 44 | this.pageURLInput.value = wrapperTag.href; 45 | 46 | let chooseNewPageFunc = null; 47 | chooseNewPageFunc = (e) => { 48 | this.setDataOnWrapper(wrapperTag, this.state); 49 | this.pageURLInput.value = this.state.url; 50 | this.chooser.input.removeEventListener('change', chooseNewPageFunc); 51 | }; 52 | 53 | this.chooseNewPageButton.onclick = (() => { 54 | this.chooser.openChooserModal(); 55 | this.chooser.input.addEventListener('change', chooseNewPageFunc); 56 | }); 57 | 58 | this.api.tooltip.onHover(this.chooseNewPageButton, this.api.i18n.t('Choose new ' + this.constructor["chooserType"]), { 59 | placement: 'top', 60 | hidingDelay: 200, 61 | }); 62 | 63 | this.targetSelect.onchange = (e) => { 64 | if (e.target.value) { 65 | this.wrapperTag.target = e.target.value; 66 | this.wrapperTag.dataset.target = e.target.value; 67 | } else if (this.wrapperTag.target) { 68 | this.wrapperTag.removeAttribute('target'); 69 | delete this.wrapperTag.dataset.target; 70 | } 71 | }; 72 | 73 | this.relSelect.onchange = (e) => { 74 | if (!e.target.value && this.pageURLInput.rel) { 75 | this.wrapperTag.removeAttribute('rel'); 76 | delete this.wrapperTag.dataset.rel; 77 | } else { 78 | this.wrapperTag.rel = e.target.value; 79 | this.wrapperTag.dataset.rel = e.target.value; 80 | } 81 | } 82 | 83 | this.relSelect.value = wrapperTag.rel || ''; 84 | this.targetSelect.value = wrapperTag.target || ''; 85 | 86 | this.container.hidden = false; 87 | 88 | 89 | } 90 | 91 | hideActions() { 92 | this.container.hidden = true; 93 | this.pageURLInput.value = ''; 94 | this.chooseNewPageButton.onclick = null; 95 | this.pageURLInput.onchange = null; 96 | this.targetSelect.onchange = null; 97 | this.relSelect.onchange = null; 98 | this.chooseNewPageButton.classList.remove( 99 | this.api.styles.inlineToolButtonActive 100 | ); 101 | } 102 | 103 | renderActions() { 104 | this.container = document.createElement('div'); 105 | this.container.classList.add("wagtail-link-tool-actions", "column"); 106 | this.container.hidden = true; 107 | 108 | const btnContainer = document.createElement('div'); 109 | btnContainer.classList.add("wagtail-link-tool-actions"); 110 | 111 | this.chooseNewPageButton = document.createElement('button'); 112 | this.chooseNewPageButton.type = 'button'; 113 | this.chooseNewPageButton.innerHTML = wagtailLinkIcon; 114 | this.chooseNewPageButton.dataset.chooserActionChoose = 'true'; 115 | this.chooseNewPageButton.classList.add( 116 | this.api.styles.inlineToolButton, 117 | ) 118 | 119 | const selectContainer = document.createElement('div'); 120 | selectContainer.classList.add("wagtail-link-tool-actions"); 121 | 122 | this.targetSelect = document.createElement('select'); 123 | this.targetSelect.innerHTML = ` 124 | 125 | 126 | 127 | `; 128 | 129 | this.relSelect = document.createElement('select'); 130 | this.relSelect.innerHTML = ` 131 | 132 | 133 | 134 | 135 | `; 136 | 137 | this.pageURLInput = document.createElement('input'); 138 | this.pageURLInput.type = 'text'; 139 | this.pageURLInput.disabled = true; 140 | this.pageURLInput.placeholder = this.api.i18n.t('URL'); 141 | this.pageURLInput.classList.add( 142 | this.api.styles.input, 143 | this.api.styles.inputUrl, 144 | ); 145 | 146 | 147 | selectContainer.appendChild(this.targetSelect); 148 | selectContainer.appendChild(this.relSelect); 149 | 150 | btnContainer.appendChild(this.pageURLInput); 151 | btnContainer.appendChild(this.chooseNewPageButton); 152 | 153 | this.container.appendChild(btnContainer); 154 | this.container.appendChild(selectContainer); 155 | 156 | return this.container; 157 | } 158 | } 159 | 160 | window.WagtailLinkTool = WagtailLinkTool; -------------------------------------------------------------------------------- /wagtail_editorjs/static/wagtail_editorjs/vendor/editorjs/tools/header.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Skipped minification because the original files appears to be already minified. 3 | * Original file: /npm/@editorjs/header@2.8.1/dist/header.umd.js 4 | * 5 | * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files 6 | */ 7 | (function(){"use strict";try{if(typeof document<"u"){var e=document.createElement("style");e.appendChild(document.createTextNode(".ce-header{outline:none}.ce-header p,.ce-header div{padding:0!important;margin:0!important}.ce-header[contentEditable=true][data-placeholder]:before{position:absolute;content:attr(data-placeholder);color:#707684;font-weight:400;display:none;cursor:text}.ce-header[contentEditable=true][data-placeholder]:empty:before{display:block}.ce-header[contentEditable=true][data-placeholder]:empty:focus:before{display:none}")),document.head.appendChild(e)}}catch(t){console.error("vite-plugin-css-injected-by-js",t)}})(); 8 | (function(n,r){typeof exports=="object"&&typeof module<"u"?module.exports=r():typeof define=="function"&&define.amd?define(r):(n=typeof globalThis<"u"?globalThis:n||self,n.Header=r())})(this,function(){"use strict";const n="",r='',o='',a='',h='',d='',u='',g='';/** 9 | * Header block for the Editor.js. 10 | * 11 | * @author CodeX (team@ifmo.su) 12 | * @copyright CodeX 2018 13 | * @license MIT 14 | * @version 2.0.0 15 | */class c{constructor({data:e,config:t,api:s,readOnly:i}){this.api=s,this.readOnly=i,this._CSS={block:this.api.styles.block,wrapper:"ce-header"},this._settings=t,this._data=this.normalizeData(e),this._element=this.getTag()}normalizeData(e){const t={};return typeof e!="object"&&(e={}),t.text=e.text||"",t.level=parseInt(e.level)||this.defaultLevel.number,t}render(){return this._element}renderSettings(){return this.levels.map(e=>({icon:e.svg,label:this.api.i18n.t(`Heading ${e.number}`),onActivate:()=>this.setLevel(e.number),closeOnActivate:!0,isActive:this.currentLevel.number===e.number}))}setLevel(e){this.data={level:e,text:this.data.text}}merge(e){const t={text:this.data.text+e.text,level:this.data.level};this.data=t}validate(e){return e.text.trim()!==""}save(e){return{text:e.innerHTML,level:this.currentLevel.number}}static get conversionConfig(){return{export:"text",import:"text"}}static get sanitize(){return{level:!1,text:{}}}static get isReadOnlySupported(){return!0}get data(){return this._data.text=this._element.innerHTML,this._data.level=this.currentLevel.number,this._data}set data(e){if(this._data=this.normalizeData(e),e.level!==void 0&&this._element.parentNode){const t=this.getTag();t.innerHTML=this._element.innerHTML,this._element.parentNode.replaceChild(t,this._element),this._element=t}e.text!==void 0&&(this._element.innerHTML=this._data.text||"")}getTag(){const e=document.createElement(this.currentLevel.tag);return e.innerHTML=this._data.text||"",e.classList.add(this._CSS.wrapper),e.contentEditable=this.readOnly?"false":"true",e.dataset.placeholder=this.api.i18n.t(this._settings.placeholder||""),e}get currentLevel(){let e=this.levels.find(t=>t.number===this._data.level);return e||(e=this.defaultLevel),e}get defaultLevel(){if(this._settings.defaultLevel){const e=this.levels.find(t=>t.number===this._settings.defaultLevel);if(e)return e;console.warn("(ง'̀-'́)ง Heading Tool: the default level specified was not found in available levels")}return this.levels[1]}get levels(){const e=[{number:1,tag:"H1",svg:r},{number:2,tag:"H2",svg:o},{number:3,tag:"H3",svg:a},{number:4,tag:"H4",svg:h},{number:5,tag:"H5",svg:d},{number:6,tag:"H6",svg:u}];return this._settings.levels?e.filter(t=>this._settings.levels.includes(t.number)):e}onPaste(e){const t=e.detail.data;let s=this.defaultLevel.number;switch(t.tagName){case"H1":s=1;break;case"H2":s=2;break;case"H3":s=3;break;case"H4":s=4;break;case"H5":s=5;break;case"H6":s=6;break}this._settings.levels&&(s=this._settings.levels.reduce((i,l)=>Math.abs(l-s)"," ").trim()}function p(s,e=!1,t=void 0){const n=document.createRange(),i=window.getSelection();n.selectNodeContents(s),t!==void 0&&(n.setStart(s,t),n.setEnd(s,t)),n.collapse(e),i.removeAllRanges(),i.addRange(n)}Element.prototype.matches||(Element.prototype.matches=Element.prototype.msMatchesSelector||Element.prototype.webkitMatchesSelector),Element.prototype.closest||(Element.prototype.closest=function(s){let e=this;if(!document.documentElement.contains(e))return null;do{if(e.matches(s))return e;e=e.parentElement||e.parentNode}while(e!==null&&e.nodeType===1);return null});class C{static get isReadOnlySupported(){return!0}static get enableLineBreaks(){return!0}static get toolbox(){return{icon:g,title:"Checklist"}}static get conversionConfig(){return{export:e=>e.items.map(({text:t})=>t).join(". "),import:e=>({items:[{text:e,checked:!1}]})}}constructor({data:e,config:t,api:n,readOnly:i}){this._elements={wrapper:null,items:[]},this.readOnly=i,this.api=n,this.data=e||{}}render(){return this._elements.wrapper=o("div",[this.CSS.baseBlock,this.CSS.wrapper]),this.data.items||(this.data.items=[{text:"",checked:!1}]),this.data.items.forEach(e=>{const t=this.createChecklistItem(e);this._elements.wrapper.appendChild(t)}),this.readOnly?this._elements.wrapper:(this._elements.wrapper.addEventListener("keydown",e=>{const[t,n]=[13,8];switch(e.keyCode){case t:this.enterPressed(e);break;case n:this.backspace(e);break}},!1),this._elements.wrapper.addEventListener("click",e=>{this.toggleCheckbox(e)}),this._elements.wrapper)}save(){let e=this.items.map(t=>{const n=this.getItemInput(t);return{text:m(n),checked:t.classList.contains(this.CSS.itemChecked)}});return e=e.filter(t=>t.text.trim().length!==0),{items:e}}validate(e){return!!e.items.length}toggleCheckbox(e){const t=e.target.closest(`.${this.CSS.item}`),n=t.querySelector(`.${this.CSS.checkboxContainer}`);n.contains(e.target)&&(t.classList.toggle(this.CSS.itemChecked),n.classList.add(this.CSS.noHover),n.addEventListener("mouseleave",()=>this.removeSpecialHoverBehavior(n),{once:!0}))}createChecklistItem(e={}){const t=o("div",this.CSS.item),n=o("span",this.CSS.checkbox),i=o("div",this.CSS.checkboxContainer),a=o("div",this.CSS.textField,{innerHTML:e.text?e.text:"",contentEditable:!this.readOnly});return e.checked&&t.classList.add(this.CSS.itemChecked),n.innerHTML=c,i.appendChild(n),t.appendChild(i),t.appendChild(a),t}enterPressed(e){e.preventDefault();const t=this.items,n=document.activeElement.closest(`.${this.CSS.item}`);if(t.indexOf(n)===t.length-1&&m(this.getItemInput(n)).length===0){const x=this.api.blocks.getCurrentBlockIndex();n.remove(),this.api.blocks.insert(),this.api.caret.setToBlock(x+1);return}const u=d(),h=f(u),r=this.createChecklistItem({text:h,checked:!1});this._elements.wrapper.insertBefore(r,n.nextSibling),p(this.getItemInput(r),!0)}backspace(e){const t=e.target.closest(`.${this.CSS.item}`),n=this.items.indexOf(t),i=this.items[n-1];if(!i||!(window.getSelection().focusOffset===0))return;e.preventDefault();const h=d(),r=this.getItemInput(i),k=r.childNodes.length;r.appendChild(h),p(r,void 0,k),t.remove()}get CSS(){return{baseBlock:this.api.styles.block,wrapper:"cdx-checklist",item:"cdx-checklist__item",itemChecked:"cdx-checklist__item--checked",noHover:"cdx-checklist__item-checkbox--no-hover",checkbox:"cdx-checklist__item-checkbox-check",textField:"cdx-checklist__item-text",checkboxContainer:"cdx-checklist__item-checkbox"}}get items(){return Array.from(this._elements.wrapper.querySelectorAll(`.${this.CSS.item}`))}removeSpecialHoverBehavior(e){e.classList.remove(this.CSS.noHover)}getItemInput(e){return e.querySelector(`.${this.CSS.textField}`)}}return C}); 9 | -------------------------------------------------------------------------------- /wagtail_editorjs/features/documents.py: -------------------------------------------------------------------------------- 1 | from typing import Any, TYPE_CHECKING 2 | from django import forms 3 | from django.urls import reverse 4 | from django.utils.translation import gettext_lazy as _ 5 | from django.views.decorators.csrf import csrf_exempt 6 | from django.utils.safestring import mark_safe 7 | from django.http import ( 8 | JsonResponse, 9 | ) 10 | 11 | from wagtail.models import Collection 12 | from wagtail.documents import ( 13 | get_document_model, 14 | ) 15 | from wagtail.documents.forms import ( 16 | get_document_form, 17 | ) 18 | if TYPE_CHECKING: 19 | from wagtail.documents.models import AbstractDocument 20 | from ..settings import ( 21 | USE_FULL_URLS, 22 | ) 23 | from ..registry import ( 24 | EditorJSFeature, 25 | EditorJSBlock, 26 | EditorJSElement, 27 | FeatureViewMixin, 28 | ) 29 | 30 | BYTE_SIZE_STEPS = [_("Bytes"), _("Kilobytes"), _("Megabytes"), _("Gigabytes"), _("Terabytes")] 31 | 32 | def filesize_to_human_readable(size: int) -> str: 33 | for unit in BYTE_SIZE_STEPS: 34 | if size < 1024: 35 | break 36 | size /= 1024 37 | return f"{size:.0f} {unit}" 38 | 39 | 40 | 41 | Document = get_document_model() 42 | DocumentForm = get_document_form(Document) 43 | 44 | 45 | class AttachesFeature(FeatureViewMixin, EditorJSFeature): 46 | allowed_tags = [ 47 | "div", "p", "span", "a", 48 | "svg", "path", 49 | ] 50 | allowed_attributes = { 51 | "div": ["class"], 52 | "p": ["class"], 53 | "span": ["class"], 54 | "a": ["class", "href", "title"], 55 | "svg": ["xmlns", "width", "height", "fill", "class", "viewBox"], 56 | "path": ["d"], 57 | } 58 | klass="CSRFAttachesTool" 59 | js=[ 60 | "wagtail_editorjs/vendor/editorjs/tools/attaches.js", 61 | "wagtail_editorjs/js/tools/attaches.js", 62 | ], 63 | 64 | 65 | def get_config(self, context: dict[str, Any] = None) -> dict: 66 | config = super().get_config(context) 67 | config.setdefault("config", {}) 68 | config["config"]["endpoint"] = reverse(f"wagtail_editorjs:{self.tool_name}") 69 | return config 70 | 71 | 72 | def validate(self, data: Any): 73 | super().validate(data) 74 | 75 | if "file" not in data["data"]: 76 | raise forms.ValidationError("Invalid file value") 77 | 78 | if "id" not in data["data"]["file"] and not data["data"]["file"]["id"] and "url" not in data["data"]["file"]: 79 | raise forms.ValidationError("Invalid id/url value") 80 | 81 | if "title" not in data["data"]: 82 | raise forms.ValidationError("Invalid title value") 83 | 84 | 85 | def render_block_data(self, block: EditorJSBlock, context = None) -> EditorJSElement: 86 | 87 | document_id = block["data"]["file"]["id"] 88 | document = Document.objects.get(pk=document_id) 89 | url = document.url 90 | 91 | if not any([url.startswith(i) for i in ["http://", "https://", "//"]])\ 92 | and context\ 93 | and "request" in context\ 94 | and USE_FULL_URLS: 95 | request = context.get("request") 96 | if request: 97 | url = request.build_absolute_uri(url) 98 | 99 | if block["data"]["title"]: 100 | title = block["data"]["title"] 101 | else: 102 | if document: 103 | title = document.title 104 | else: 105 | title = url 106 | 107 | return EditorJSElement( 108 | "div", 109 | [ 110 | EditorJSElement( 111 | "p", 112 | EditorJSElement( 113 | "a", 114 | title, 115 | attrs={"href": url}, 116 | ), 117 | attrs={"class": "attaches-title"}, 118 | ), 119 | EditorJSElement( 120 | "span", 121 | filesize_to_human_readable(document.file.size), 122 | attrs={"class": "attaches-size"}, 123 | ), 124 | EditorJSElement( 125 | "a", 126 | mark_safe(""" 127 | 128 | 129 | """), 130 | attrs={ 131 | "title": _("Download"), 132 | "href": url, 133 | "class": "attaches-link", 134 | # "data-id": document_id, 135 | }, 136 | ) 137 | ], 138 | attrs={"class": "attaches"}, 139 | ) 140 | 141 | 142 | @classmethod 143 | def get_test_data(cls): 144 | instance = Document.objects.first() 145 | return [ 146 | { 147 | "file": { 148 | "id": instance.pk, 149 | }, 150 | "title": "Document", 151 | }, 152 | ] 153 | 154 | @csrf_exempt 155 | def handle_post(self, request): 156 | file = request.FILES.get('file') 157 | if not file: 158 | return JsonResponse({ 159 | 'success': False, 160 | 'errors': { 161 | 'file': ["This field is required."] 162 | } 163 | }, status=400) 164 | 165 | filename = file.name 166 | title = request.POST.get('title', filename) 167 | 168 | collection = Collection.get_first_root_node().id 169 | form = DocumentForm({ 'title': title, 'collection': collection }, request.FILES) 170 | if form.is_valid(): 171 | document: AbstractDocument = form.save(commit=False) 172 | 173 | hash = document.get_file_hash() 174 | existing = Document.objects.filter(file_hash=hash) 175 | if existing.exists(): 176 | exists: AbstractDocument = existing.first() 177 | return JsonResponse({ 178 | 'success': True, 179 | 'file': { 180 | 'id': exists.pk, 181 | 'title': exists.title, 182 | 'size': exists.file.size, 183 | 'url': exists.url, 184 | 'upload_replaced': True, 185 | 'reuploaded_by_user': request.user.pk, 186 | } 187 | }) 188 | 189 | document.uploaded_by_user = request.user 190 | document.save() 191 | return JsonResponse({ 192 | 'success': True, 193 | 'file': { 194 | 'id': document.pk, 195 | 'title': document.title, 196 | 'size': document.file.size, 197 | 'url': document.url, 198 | 'upload_replaced': False, 199 | 'reuploaded_by_user': None, 200 | } 201 | }) 202 | else: 203 | return JsonResponse({ 204 | 'success': False, 205 | 'errors': form.errors, 206 | }, status=400) 207 | 208 | -------------------------------------------------------------------------------- /wagtail_editorjs/forms.py: -------------------------------------------------------------------------------- 1 | from django.utils.functional import cached_property 2 | from django import forms 3 | from django.forms import ( 4 | fields as formfields, 5 | widgets 6 | ) 7 | from wagtail import hooks 8 | 9 | from datetime import datetime 10 | 11 | from .hooks import ( 12 | BUILD_CONFIG_HOOK, 13 | ) 14 | from .registry import ( 15 | EDITOR_JS_FEATURES, 16 | get_features, 17 | TemplateNotSpecifiedError, 18 | ) 19 | 20 | def _get_feature_scripts(feature, method, *args, list_obj = None, **kwargs): 21 | get_scripts = getattr(feature, method, None) 22 | if get_scripts is None: 23 | raise AttributeError(f"Feature {feature} does not have a {method} method") 24 | 25 | scripts = get_scripts(*args, **kwargs) 26 | 27 | if list_obj is None: 28 | list_obj = [] 29 | 30 | for file in get_scripts(): 31 | if file not in list_obj: 32 | if isinstance(file, (list, tuple)): 33 | list_obj.extend(file) 34 | else: 35 | list_obj.append(file) 36 | return scripts 37 | 38 | class EditorJSWidget(widgets.Input): 39 | """ 40 | A widget which renders the EditorJS editor. 41 | 42 | All features are allowed to register CSS and JS files. 43 | 44 | They can also optionally include sub-templates 45 | inside of the widget container. 46 | """ 47 | template_name = 'wagtail_editorjs/widgets/editorjs.html' 48 | accepts_features = True 49 | input_type = 'hidden' 50 | 51 | def __init__(self, features: list[str] = None, tools_config: dict = None, attrs: dict = None): 52 | super().__init__(attrs) 53 | 54 | self.features = get_features(features) 55 | self.tools_config = tools_config or {} 56 | self.autofocus = self.attrs.get('autofocus', False) 57 | self.placeholder = self.attrs.get('placeholder', "") 58 | 59 | def build_attrs(self, base_attrs, extra_attrs): 60 | attrs = super().build_attrs(base_attrs, extra_attrs) 61 | attrs['data-controller'] = 'editorjs-widget' 62 | return attrs 63 | 64 | def get_context(self, name, value, attrs): 65 | context = super().get_context(name, value, attrs) 66 | config = EDITOR_JS_FEATURES.build_config(self.features, context) 67 | config["holder"] = f"{context['widget']['attrs']['id']}-wagtail-editorjs-widget" 68 | 69 | tools = config.get('tools', {}) 70 | 71 | for tool_name, tool_config in self.tools_config.items(): 72 | if tool_name in tools: 73 | cfg = tools[tool_name] 74 | cpy = tool_config.copy() 75 | cpy.update(cfg) 76 | tools[tool_name] = cpy 77 | else: 78 | raise ValueError(f"Tool {tool_name} not found in tools; did you include the feature?") 79 | 80 | for hook in hooks.get_hooks(BUILD_CONFIG_HOOK): 81 | hook(self, context, config) 82 | 83 | context['widget']['features'] = self.features 84 | inclusion_templates = [] 85 | for feature in self.features: 86 | try: 87 | inclusion_templates.append( 88 | EDITOR_JS_FEATURES[feature].render_template(context) 89 | ) 90 | except TemplateNotSpecifiedError: 91 | pass 92 | 93 | context['widget']['inclusion_templates'] = inclusion_templates 94 | context['widget']['config'] = config 95 | return context 96 | 97 | @cached_property 98 | def media(self): 99 | js = [ 100 | "wagtail_editorjs/vendor/editorjs/editorjs.umd.js", 101 | "wagtail_editorjs/js/editorjs-widget.js", 102 | "wagtail_editorjs/js/tools/wagtail-block-tool.js", 103 | "wagtail_editorjs/js/tools/wagtail-inline-tool.js", 104 | ] 105 | css = [ 106 | "wagtail_editorjs/css/editorjs-widget.css", 107 | # "wagtail_editorjs/css/frontend.css", 108 | ] 109 | 110 | feature_mapping = EDITOR_JS_FEATURES.get_by_weight( 111 | self.features, 112 | ) 113 | 114 | for feature in feature_mapping.values(): 115 | _get_feature_scripts(feature, "get_js", list_obj=js) 116 | _get_feature_scripts(feature, "get_css", list_obj=css) 117 | 118 | js.extend([ 119 | "wagtail_editorjs/js/editorjs-widget-controller.js", 120 | ]) 121 | 122 | return widgets.Media( 123 | js=js, 124 | css={'all': css} 125 | ) 126 | 127 | 128 | 129 | class EditorJSFormField(formfields.JSONField): 130 | def __init__(self, features: list[str] = None, tools_config: dict = None, *args, **kwargs): 131 | self.features = get_features(features) 132 | self.tools_config = tools_config or {} 133 | super().__init__(*args, **kwargs) 134 | 135 | @cached_property 136 | def widget(self): 137 | return EditorJSWidget( 138 | features=self.features, 139 | tools_config=self.tools_config, 140 | ) 141 | 142 | def to_python(self, value): 143 | value = super().to_python(value) 144 | 145 | if value is None: 146 | return value 147 | 148 | value = EDITOR_JS_FEATURES.to_python( 149 | self.features, value 150 | ) 151 | 152 | return value 153 | 154 | def prepare_value(self, value): 155 | if value is None: 156 | return super().prepare_value(value) 157 | 158 | if isinstance(value, formfields.InvalidJSONInput): 159 | return value 160 | 161 | if not isinstance(value, dict): 162 | return value 163 | 164 | value = EDITOR_JS_FEATURES.value_for_form( 165 | self.features, value 166 | ) 167 | 168 | return super().prepare_value(value) 169 | 170 | def validate(self, value) -> None: 171 | super().validate(value) 172 | 173 | if value is None and self.required: 174 | raise forms.ValidationError("This field is required") 175 | 176 | if value: 177 | if not isinstance(value, dict): 178 | raise forms.ValidationError("Invalid EditorJS JSON object, expected a dictionary") 179 | 180 | if "time" not in value: 181 | raise forms.ValidationError("Invalid EditorJS JSON object, missing time") 182 | 183 | if "version" not in value: 184 | raise forms.ValidationError("Invalid EditorJS JSON object, missing version") 185 | 186 | time = value["time"] # 1713272305659 187 | if not isinstance(time, (int, float)): 188 | raise forms.ValidationError("Invalid EditorJS JSON object, time is not an integer") 189 | 190 | time_invalid = "Invalid EditorJS JSON object, time is invalid" 191 | try: 192 | time = datetime.fromtimestamp(time / 1000) 193 | except: 194 | raise forms.ValidationError(time_invalid) 195 | 196 | if time is None: 197 | raise forms.ValidationError(time_invalid) 198 | 199 | if value and self.required: 200 | if "blocks" not in value: 201 | raise forms.ValidationError("Invalid JSON object") 202 | 203 | if not value["blocks"]: 204 | raise forms.ValidationError("This field is required") 205 | 206 | EDITOR_JS_FEATURES.validate_for_tools( 207 | self.features, value 208 | ) 209 | 210 | 211 | 212 | -------------------------------------------------------------------------------- /wagtail_editorjs/static/wagtail_editorjs/vendor/editorjs/tools/underline.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Skipped minification because the original files appears to be already minified. 3 | * Original file: /npm/@editorjs/underline@1.1.0/dist/bundle.js 4 | * 5 | * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files 6 | */ 7 | !function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.Underline=t():e.Underline=t()}(window,(function(){return function(e){var t={};function n(r){if(t[r])return t[r].exports;var o=t[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,n),o.l=!0,o.exports}return n.m=e,n.c=t,n.d=function(e,t,r){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:r})},n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(n.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)n.d(r,o,function(t){return e[t]}.bind(null,o));return r},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="/",n(n.s=4)}([function(e,t,n){var r=n(1),o=n(2);"string"==typeof(o=o.__esModule?o.default:o)&&(o=[[e.i,o,""]]);var i={insert:"head",singleton:!1};r(o,i);e.exports=o.locals||{}},function(e,t,n){"use strict";var r,o=function(){return void 0===r&&(r=Boolean(window&&document&&document.all&&!window.atob)),r},i=function(){var e={};return function(t){if(void 0===e[t]){var n=document.querySelector(t);if(window.HTMLIFrameElement&&n instanceof window.HTMLIFrameElement)try{n=n.contentDocument.head}catch(e){n=null}e[t]=n}return e[t]}}(),a=[];function u(e){for(var t=-1,n=0;n'}}])&&o(t.prototype,n),r&&o(t,r),Object.defineProperty(t,"prototype",{writable:!1}),e}()}]).default})); --------------------------------------------------------------------------------