├── tests ├── testapp │ ├── __init__.py │ ├── urls.py │ ├── admin.py │ ├── templates │ │ └── editor.html │ ├── static │ │ └── testapp │ │ │ └── blue-bold.js │ ├── settings.py │ ├── test_editor_destroy.py │ ├── models.py │ ├── test_prose_editor.py │ ├── test_form_field.py │ ├── test_checks.py │ └── test_configurable.py ├── manage.py ├── README.md └── conftest.py ├── django_prose_editor ├── .gitignore ├── __init__.py ├── locale │ ├── de │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ ├── djangojs.mo │ │ │ ├── django.po │ │ │ └── djangojs.po │ └── pl │ │ └── LC_MESSAGES │ │ ├── django.mo │ │ ├── djangojs.mo │ │ ├── django.po │ │ └── djangojs.po ├── static │ └── django_prose_editor │ │ ├── material-icons.woff2 │ │ ├── material-icons.css │ │ ├── material-icons.css.map │ │ ├── default.js │ │ ├── configurable.js │ │ ├── overrides.css │ │ ├── default.js.map │ │ └── configurable.js.map ├── apps.py ├── sanitized.py ├── widgets.py └── fields.py ├── docs ├── requirements.txt ├── changelog.rst ├── Makefile ├── bundlers.rst ├── make.bat ├── index.rst ├── conf.py ├── installation.rst ├── development.rst ├── forms.rst ├── legacy.rst ├── presets.rst ├── sanitization.rst ├── menu.rst ├── configuration.rst ├── system_checks.rst └── textclass.rst ├── src ├── locale ├── material-icons.woff2 ├── history.js ├── nospellcheck.js ├── material-icons.css ├── fullscreen.css ├── pm.js ├── dialog.css ├── editor.css ├── typographic.js ├── fullscreen.js ├── textClass.js ├── default.js ├── configurable.js ├── editor.js ├── link.js ├── menu.css ├── html.js ├── orderedList.js ├── utils.js ├── table.js └── overrides.css ├── .gitattributes ├── postcss.config.js ├── .editorconfig ├── .gitignore ├── .readthedocs.yaml ├── tox.ini ├── README.rst ├── biome.json ├── .github └── workflows │ └── tests.yml ├── .pre-commit-config.yaml ├── LICENSE ├── package.json ├── rslib.config.mjs ├── pyproject.toml └── AGENTS.md /tests/testapp/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_prose_editor/.gitignore: -------------------------------------------------------------------------------- 1 | lib/ 2 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | Sphinx 2 | furo 3 | -------------------------------------------------------------------------------- /src/locale: -------------------------------------------------------------------------------- 1 | ../django_prose_editor/locale/ -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CHANGELOG.rst 2 | -------------------------------------------------------------------------------- /django_prose_editor/__init__.py: -------------------------------------------------------------------------------- 1 | version = "0.22.4" 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | django_prose_editor/static/django_prose_editor/* binary 2 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [require("autoprefixer")()], 3 | } 4 | -------------------------------------------------------------------------------- /src/material-icons.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feincms/django-prose-editor/HEAD/src/material-icons.woff2 -------------------------------------------------------------------------------- /django_prose_editor/locale/de/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feincms/django-prose-editor/HEAD/django_prose_editor/locale/de/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /django_prose_editor/locale/pl/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feincms/django-prose-editor/HEAD/django_prose_editor/locale/pl/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /django_prose_editor/locale/de/LC_MESSAGES/djangojs.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feincms/django-prose-editor/HEAD/django_prose_editor/locale/de/LC_MESSAGES/djangojs.mo -------------------------------------------------------------------------------- /django_prose_editor/locale/pl/LC_MESSAGES/djangojs.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feincms/django-prose-editor/HEAD/django_prose_editor/locale/pl/LC_MESSAGES/djangojs.mo -------------------------------------------------------------------------------- /src/history.js: -------------------------------------------------------------------------------- 1 | import { UndoRedo } from "@tiptap/extensions" 2 | export { UndoRedo } 3 | // Rename back to old export 4 | export const History = UndoRedo.extend({ name: "history" }) 5 | -------------------------------------------------------------------------------- /django_prose_editor/static/django_prose_editor/material-icons.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feincms/django-prose-editor/HEAD/django_prose_editor/static/django_prose_editor/material-icons.woff2 -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | end_of_line = lf 6 | insert_final_newline = true 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | indent_style = space 10 | indent_size = 2 11 | 12 | [*.py] 13 | indent_size = 4 14 | -------------------------------------------------------------------------------- /django_prose_editor/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class DjangoProseEditorConfig(AppConfig): 5 | name = "django_prose_editor" 6 | 7 | def ready(self): 8 | # Import system checks 9 | from . import checks # noqa: F401, PLC0415 10 | -------------------------------------------------------------------------------- /tests/testapp/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.shortcuts import render 3 | from django.urls import path 4 | 5 | 6 | urlpatterns = [ 7 | path("admin/", admin.site.urls), 8 | path("editor/", lambda request: render(request, "editor.html")), 9 | ] 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | \#*# 3 | /build 4 | /.bundle 5 | .coverage 6 | /data.db 7 | /dist 8 | .DS_Store 9 | /dump.rdb 10 | *.egg-info 11 | /.env 12 | /htdocs/e 13 | /.idea 14 | /log 15 | /media 16 | /node_modules 17 | *.pyc 18 | /static 19 | .*.sw* 20 | /tmp 21 | .tox 22 | /vendor 23 | /venv 24 | /.vscode 25 | yarn-error.log 26 | build 27 | test-results 28 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | version: 2 5 | 6 | build: 7 | os: ubuntu-24.04 8 | tools: 9 | python: "3.11" 10 | 11 | sphinx: 12 | configuration: docs/conf.py 13 | 14 | python: 15 | install: 16 | - requirements: docs/requirements.txt 17 | -------------------------------------------------------------------------------- /tests/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | from os.path import abspath, dirname 5 | 6 | 7 | if __name__ == "__main__": 8 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testapp.settings") 9 | 10 | sys.path.insert(0, dirname(dirname(abspath(__file__)))) 11 | 12 | from django.core.management import execute_from_command_line 13 | 14 | execute_from_command_line(sys.argv) 15 | -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | # Testing 2 | 3 | ## Running Tests 4 | 5 | To run all tests: 6 | ```bash 7 | tox -e py313-dj52 8 | ``` 9 | 10 | To run specific test files: 11 | ```bash 12 | tox -e py313-dj52 -- tests/testapp/test_config.py -v 13 | ``` 14 | 15 | To run specific test methods: 16 | ```bash 17 | tox -e py313-dj52 -- tests/testapp/test_config.py::ConfigFunctionsTestCase::test_expand_extensions_without_auto_dependencies -v 18 | ``` 19 | 20 | You can pass any pytest arguments after the double dash (`--`). 21 | -------------------------------------------------------------------------------- /django_prose_editor/static/django_prose_editor/material-icons.css: -------------------------------------------------------------------------------- 1 | @font-face{font-family:Material Icons;font-style:normal;font-weight:400;src:url(./material-icons.woff2)format("woff2")}.material-icons{letter-spacing:normal;text-transform:none;white-space:nowrap;word-wrap:normal;-webkit-font-feature-settings:"liga";-webkit-font-smoothing:antialiased;direction:ltr;font-family:Material Icons;font-size:24px;font-style:normal;font-weight:400;line-height:1;display:inline-block} 2 | /*# sourceMappingURL=material-icons.css.map*/ -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = python -msphinx 7 | SPHINXPROJ = test 8 | SOURCEDIR = . 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /src/nospellcheck.js: -------------------------------------------------------------------------------- 1 | import { Extension } from "@tiptap/core" 2 | import { Plugin } from "@tiptap/pm/state" 3 | 4 | export const NoSpellCheck = Extension.create({ 5 | name: "noSpellCheck", 6 | 7 | addProseMirrorPlugins() { 8 | return [ 9 | new Plugin({ 10 | view(editorView) { 11 | return new NoSpellCheckPlugin(editorView) 12 | }, 13 | }), 14 | ] 15 | }, 16 | }) 17 | 18 | class NoSpellCheckPlugin { 19 | constructor(editorView) { 20 | this.editorView = editorView 21 | this.editorView.dom.setAttribute("spellcheck", "false") 22 | } 23 | 24 | update() {} 25 | 26 | destroy() { 27 | this.editorView.dom.removeAttribute("spellcheck") 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /django_prose_editor/sanitized.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | 3 | from django_prose_editor.fields import ProseEditorField, _actually_empty 4 | 5 | 6 | def _nh3_sanitizer(): 7 | import nh3 # noqa: PLC0415 8 | 9 | attributes = deepcopy(nh3.ALLOWED_ATTRIBUTES) 10 | attributes["a"].add("target") 11 | attributes["ol"] |= {"start", "type"} 12 | 13 | cleaner = nh3.Cleaner(attributes=attributes) 14 | return lambda x: _actually_empty(cleaner.clean(x)) 15 | 16 | 17 | class SanitizedProseEditorField(ProseEditorField): 18 | def __init__(self, *args, **kwargs): 19 | if "sanitize" not in kwargs: 20 | kwargs["sanitize"] = _nh3_sanitizer() 21 | super().__init__(*args, **kwargs) 22 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{310}-dj{42} 4 | py{313}-dj{52,main} 5 | 6 | [testenv] 7 | usedevelop = true 8 | extras = all,tests 9 | passenv = 10 | HOME 11 | PYTHONPATH 12 | DISPLAY 13 | XAUTHORITY 14 | commands = 15 | playwright install chromium 16 | pytest --cov=django_prose_editor --cov-report=term-missing --browser chromium tests/testapp {posargs} 17 | deps = 18 | dj42: Django>=4.2,<5.0 19 | dj52: Django>=5.2,<6.0 20 | djmain: https://github.com/django/django/archive/main.tar.gz 21 | 22 | # The default testenv now includes Playwright 23 | 24 | [testenv:docs] 25 | deps = 26 | -r docs/requirements.txt 27 | changedir = docs 28 | commands = make html 29 | skip_install = true 30 | allowlist_externals = make 31 | -------------------------------------------------------------------------------- /src/material-icons.css: -------------------------------------------------------------------------------- 1 | /* 2 | npx google-font-downloader "https://fonts.googleapis.com/icon?family=Material+Icons" 3 | Current version: v142 4 | */ 5 | 6 | /* fallback */ 7 | @font-face { 8 | font-family: "Material Icons"; 9 | font-style: normal; 10 | font-weight: 400; 11 | src: url(./material-icons.woff2) format("woff2"); 12 | } 13 | 14 | .material-icons { 15 | /* biome-ignore lint/a11y/useGenericFontNames: There's no generic font name for this */ 16 | font-family: "Material Icons"; 17 | font-weight: normal; 18 | font-style: normal; 19 | font-size: 24px; 20 | line-height: 1; 21 | letter-spacing: normal; 22 | text-transform: none; 23 | display: inline-block; 24 | white-space: nowrap; 25 | word-wrap: normal; 26 | direction: ltr; 27 | -webkit-font-feature-settings: "liga"; 28 | -webkit-font-smoothing: antialiased; 29 | } 30 | -------------------------------------------------------------------------------- /docs/bundlers.rst: -------------------------------------------------------------------------------- 1 | Usage with JavaScript bundlers 2 | ============================== 3 | 4 | If you're using a bundler such as esbuild, rspack or webpack you have to ensure 5 | that the django-prose-editor JavaScript library is treated as an external and 6 | not bundled into a centeral JavaScript file. In the case of rspack this means 7 | adding the following lines to your rspack configuration: 8 | 9 | .. code-block:: javascript 10 | 11 | module.exports = { 12 | // ... 13 | experiments: { outputModule: true }, 14 | externals: { 15 | "django-prose-editor/editor": "module django-prose-editor/editor", 16 | "django-prose-editor/configurable": "module django-prose-editor/configurable", 17 | }, 18 | } 19 | 20 | This makes rspack emit ES modules and preserves imports of 21 | ``django-prose-editor/editor`` and similar in the output instead of trying to 22 | bundle the library. 23 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=python -msphinx 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=build 12 | set SPHINXPROJ=test 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The Sphinx module was not found. Make sure you have Sphinx installed, 20 | echo.then set the SPHINXBUILD environment variable to point to the full 21 | echo.path of the 'sphinx-build' executable. Alternatively you may add the 22 | echo.Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | =================== 2 | django-prose-editor 3 | =================== 4 | 5 | Prose editor for the Django admin based on ProseMirror and Tiptap. `Announcement blog post `__. 6 | 7 | 8 | Intro 9 | ===== 10 | 11 | After installing the package (using ``pip install 12 | django-prose-editor[sanitize]``) the following should get you started: 13 | 14 | .. code-block:: python 15 | 16 | from django_prose_editor.fields import ProseEditorField 17 | 18 | content = ProseEditorField( 19 | extensions={ 20 | "Bold": True, 21 | "Italic": True, 22 | "BulletList": True, 23 | "ListItem": True, 24 | "Link": True, 25 | }, 26 | sanitize=True, # Server side sanitization is strongly recommended. 27 | ) 28 | 29 | Check the `documentation `__. 30 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/2.3.8/schema.json", 3 | "assist": { 4 | "actions": { 5 | "source": { 6 | "organizeImports": "on" 7 | } 8 | } 9 | }, 10 | "formatter": { 11 | "enabled": true, 12 | "useEditorconfig": true 13 | }, 14 | "linter": { 15 | "enabled": true, 16 | "rules": { 17 | "recommended": true, 18 | "a11y": { 19 | "noSvgWithoutTitle": "off" 20 | }, 21 | "complexity": { 22 | "noImportantStyles": "off" 23 | }, 24 | "style": { 25 | "noParameterAssign": "off" 26 | }, 27 | "suspicious": { 28 | "noAssignInExpressions": "off" 29 | } 30 | } 31 | }, 32 | "javascript": { 33 | "formatter": { 34 | "semicolons": "asNeeded" 35 | }, 36 | "globals": [] 37 | }, 38 | "css": { 39 | "formatter": { 40 | "enabled": true 41 | }, 42 | "linter": { 43 | "enabled": true 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /django_prose_editor/locale/de/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2024-08-26 14:45+0200\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 20 | #: widgets.py:28 21 | msgid "URL" 22 | msgstr "URL" 23 | 24 | #: widgets.py:29 25 | msgid "Title" 26 | msgstr "Titel" 27 | 28 | #: widgets.py:30 29 | msgid "Update" 30 | msgstr "Aktualisieren" 31 | 32 | #: widgets.py:31 33 | msgid "Cancel" 34 | msgstr "Abbrechen" 35 | -------------------------------------------------------------------------------- /django_prose_editor/locale/pl/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # Polish translations for django-prose-editor. 2 | # Copyright (C) 2024 Matthias Kestenholz 3 | # This file is distributed under the same license as the django-prose-editor package. 4 | # Karol Gunia , 2025. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: django-prose-editor\n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2024-08-26 14:45+0200\n" 11 | "PO-Revision-Date: 2025-12-05 14:30+0100\n" 12 | "Last-Translator: Karol Gunia \n" 13 | "Language-Team: Polish\n" 14 | "Language: pl\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" 19 | 20 | #: widgets.py:28 21 | msgid "URL" 22 | msgstr "URL" 23 | 24 | #: widgets.py:29 25 | msgid "Title" 26 | msgstr "Tytuł" 27 | 28 | #: widgets.py:30 29 | msgid "Update" 30 | msgstr "Aktualizuj" 31 | 32 | #: widgets.py:31 33 | msgid "Cancel" 34 | msgstr "Anuluj" 35 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | tests: 11 | name: Python ${{ matrix.python-version }} 12 | runs-on: ubuntu-latest 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | python-version: 17 | - '3.10' 18 | - '3.13' 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v5 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip tox pytest-playwright 29 | 30 | # Install playwright system dependencies 31 | - name: Install Playwright browsers 32 | run: | 33 | playwright install --with-deps chromium 34 | 35 | - name: Run tox targets for ${{ matrix.python-version }} 36 | run: | 37 | ENV_PREFIX=$(tr -C -d "0-9" <<< "${{ matrix.python-version }}") 38 | TOXENV=$(tox --listenvs | grep "^py$ENV_PREFIX" | tr '\n' ',') python -m tox 39 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | =================== 2 | django-prose-editor 3 | =================== 4 | 5 | Prose editor for the Django admin based on ProseMirror and Tiptap. `Announcement blog post `__. 6 | 7 | After installing the package (using ``pip install 8 | django-prose-editor[sanitize]``) the following should get you started: 9 | 10 | .. code-block:: python 11 | 12 | from django_prose_editor.fields import ProseEditorField 13 | 14 | content = ProseEditorField( 15 | extensions={ 16 | "Bold": True, 17 | "Italic": True, 18 | "BulletList": True, 19 | "Link": True, 20 | }, 21 | sanitize=True # Enable sanitization based on extension configuration 22 | ) 23 | 24 | 25 | .. toctree:: 26 | :maxdepth: 2 27 | :caption: Table of contents 28 | 29 | installation 30 | configuration 31 | sanitization 32 | custom_extensions 33 | menu 34 | nodeclass 35 | textclass 36 | presets 37 | legacy 38 | forms 39 | system_checks 40 | bundlers 41 | development 42 | changelog 43 | -------------------------------------------------------------------------------- /src/fullscreen.css: -------------------------------------------------------------------------------- 1 | .prose-editor.fullscreen { 2 | position: fixed !important; 3 | top: 0 !important; 4 | left: 0 !important; 5 | width: 100vw !important; 6 | height: 100vh !important; 7 | max-width: none !important; 8 | max-height: none !important; 9 | z-index: 9999 !important; 10 | margin: 0 !important; 11 | padding: 20px !important; 12 | background: var(--_b) !important; 13 | overflow-y: auto !important; 14 | display: flex !important; 15 | flex-direction: column !important; 16 | } 17 | 18 | .prose-editor.fullscreen .prose-menubar { 19 | padding: 8px 20px !important; 20 | border-radius: 0 !important; 21 | width: 100% !important; 22 | } 23 | 24 | .prose-editor.fullscreen .prose-menubar--floating { 25 | position: fixed !important; 26 | top: 0 !important; 27 | left: 0 !important; 28 | right: 0 !important; 29 | z-index: 10000 !important; 30 | } 31 | 32 | .prose-editor.fullscreen .ProseMirror { 33 | flex: 1 !important; 34 | max-height: none !important; 35 | overflow-y: auto !important; 36 | padding: 1em !important; 37 | box-shadow: none !important; 38 | border: 1px solid var(--_r) !important; 39 | border-radius: 4px !important; 40 | } 41 | -------------------------------------------------------------------------------- /tests/testapp/admin.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.contrib import admin 3 | 4 | from django_prose_editor.widgets import ProseEditorWidget 5 | from testapp import models 6 | 7 | 8 | @admin.register(models.ProseEditorModel) 9 | class ProseEditorModelAdmin(admin.ModelAdmin): 10 | pass 11 | 12 | 13 | class SanitizedProseEditorModelForm(forms.ModelForm): 14 | class Meta: 15 | widgets = { 16 | "description": ProseEditorWidget( 17 | config={ 18 | "types": ["paragraph", "strong", "em", "link", "heading"], 19 | "history": False, 20 | "html": False, 21 | "typographic": True, 22 | } 23 | ) 24 | } 25 | 26 | 27 | @admin.register(models.SanitizedProseEditorModel) 28 | class SanitizedProseEditorModelAdmin(admin.ModelAdmin): 29 | form = SanitizedProseEditorModelForm 30 | 31 | 32 | @admin.register(models.TableProseEditorModel) 33 | class TableProseEditorModelAdmin(admin.ModelAdmin): 34 | pass 35 | 36 | 37 | @admin.register(models.ConfigurableProseEditorModel) 38 | class ConfigurableProseEditorModelAdmin(admin.ModelAdmin): 39 | pass 40 | -------------------------------------------------------------------------------- /src/pm.js: -------------------------------------------------------------------------------- 1 | export * as commands from "@tiptap/pm/commands" 2 | export * as history from "@tiptap/pm/history" 3 | export * as keymap from "@tiptap/pm/keymap" 4 | export * as model from "@tiptap/pm/model" 5 | export * as state from "@tiptap/pm/state" 6 | export * as tables from "@tiptap/pm/tables" 7 | export * as transform from "@tiptap/pm/transform" 8 | export * as view from "@tiptap/pm/view" 9 | 10 | // No collaboration functionality for now 11 | // export * as collab from "@tiptap/pm/collab" 12 | // 13 | // Already included as extensions 14 | // export * as dropcursor from "@tiptap/pm/dropcursor" 15 | // export * as gapcursor from "@tiptap/pm/gapcursor" 16 | // 17 | // Not necessary, we have the schema 18 | // export * as schema-basic from "@tiptap/pm/schema-basic" 19 | // export * as schema-list from "@tiptap/pm/schema-list" 20 | // 21 | // We don't want the pm menu 22 | // export * as menu from "@tiptap/pm/menu" 23 | // 24 | // HTML editor, not Markdown editor :-/ 25 | // export * as markdown from "@tiptap/pm/markdown" 26 | // 27 | // Wait and see... 28 | // export * as changeset from "@tiptap/pm/changeset" 29 | // export * as inputrules from "@tiptap/pm/inputrules" 30 | // export * as trailing-node from "@tiptap/pm/trailing-node" 31 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | exclude: ".yarn/|yarn.lock|django_prose_editor/static/" 2 | repos: 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v6.0.0 5 | hooks: 6 | - id: check-added-large-files 7 | - id: check-builtin-literals 8 | - id: check-executables-have-shebangs 9 | - id: check-merge-conflict 10 | - id: check-toml 11 | - id: check-yaml 12 | - id: detect-private-key 13 | - id: end-of-file-fixer 14 | - id: mixed-line-ending 15 | - id: trailing-whitespace 16 | - repo: https://github.com/adamchainz/django-upgrade 17 | rev: 1.29.1 18 | hooks: 19 | - id: django-upgrade 20 | args: [--target-version, "4.2"] 21 | - repo: https://github.com/astral-sh/ruff-pre-commit 22 | rev: v0.14.8 23 | hooks: 24 | - id: ruff-check 25 | args: [--unsafe-fixes] 26 | - id: ruff-format 27 | - repo: https://github.com/biomejs/pre-commit 28 | rev: v2.3.8 29 | hooks: 30 | - id: biome-check 31 | args: [--unsafe] 32 | verbose: true 33 | - repo: https://github.com/tox-dev/pyproject-fmt 34 | rev: v2.11.1 35 | hooks: 36 | - id: pyproject-fmt 37 | - repo: https://github.com/abravalheri/validate-pyproject 38 | rev: v0.24.1 39 | hooks: 40 | - id: validate-pyproject 41 | -------------------------------------------------------------------------------- /tests/testapp/templates/editor.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Editor Destroy Test 5 | 6 | 7 | 8 |
9 | 10 |
11 | 12 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | 4 | templates_path = ["_templates"] 5 | master_doc = "index" 6 | 7 | project = "django-prose-editor" 8 | copyright = f"{datetime.now().year}, the django-prose-editor developers" 9 | 10 | exclude_patterns = ["_build"] 11 | 12 | html_theme = "furo" 13 | html_theme_options = { 14 | "source_repository": "https://github.com/matthiask/django-prose-editor/", 15 | "source_branch": "main", 16 | "source_directory": "docs/", 17 | "footer_icons": [ 18 | { 19 | "name": "GitHub", 20 | "url": "https://github.com/matthiask/django-prose-editor/", 21 | "html": """ 22 | 23 | 24 | 25 | """, 26 | "class": "", 27 | }, 28 | ], 29 | } 30 | -------------------------------------------------------------------------------- /django_prose_editor/static/django_prose_editor/material-icons.css.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"material-icons.css","sources":["../../../home/matthias/projects/django-prose-editor/src/material-icons.css","../../../src/material-icons.css"],"sourcesContent":["/*\nnpx google-font-downloader \"https://fonts.googleapis.com/icon?family=Material+Icons\"\nCurrent version: v142\n*/\n\n/* fallback */\n@font-face {\n font-family: \"Material Icons\";\n font-style: normal;\n font-weight: 400;\n src: url(./material-icons.woff2) format(\"woff2\");\n}\n\n.material-icons {\n /* biome-ignore lint/a11y/useGenericFontNames: There's no generic font name for this */\n font-family: \"Material Icons\";\n font-weight: normal;\n font-style: normal;\n font-size: 24px;\n line-height: 1;\n letter-spacing: normal;\n text-transform: none;\n display: inline-block;\n white-space: nowrap;\n word-wrap: normal;\n direction: ltr;\n -webkit-font-feature-settings: \"liga\";\n -webkit-font-smoothing: antialiased;\n}\n","@font-face {\n font-family: Material Icons;\n font-style: normal;\n font-weight: 400;\n src: url(\"./material-icons.woff2\") format(\"woff2\");\n}\n\n.material-icons {\n letter-spacing: normal;\n text-transform: none;\n white-space: nowrap;\n word-wrap: normal;\n -webkit-font-feature-settings: \"liga\";\n -webkit-font-smoothing: antialiased;\n direction: ltr;\n font-family: Material Icons;\n font-size: 24px;\n font-style: normal;\n font-weight: normal;\n line-height: 1;\n display: inline-block;\n}\n"],"names":[],"mappings":"AAKA,uHCEA"} -------------------------------------------------------------------------------- /django_prose_editor/static/django_prose_editor/default.js: -------------------------------------------------------------------------------- 1 | import{Blockquote as e,Bold as t,BulletList as l,Document as r,Dropcursor as i,Gapcursor as o,HTML as d,HardBreak as s,Heading as n,History as u,HorizontalRule as a,Italic as p,Link as c,ListItem as b,Menu as g,NoSpellCheck as h,OrderedList as L,Paragraph as k,Strike as B,Subscript as f,Superscript as S,Table as m,TableCell as v,TableHeader as _,TableRow as y,Text as H,Typographic as O,Underline as T,createTextareaEditor as j,initializeEditors as w}from"django-prose-editor/editor";let z="data-django-prose-editor-default";function E(w,q=null){let I;if(w.closest(".prose-editor"))return;q||(q=JSON.parse(w.getAttribute(z)));let R=["Blockquote","Bold","BulletList","Heading","HorizontalRule","Italic","Link","OrderedList","Strike","Subscript","Superscript","Underline"],U=(I=q.types,(...e)=>{let t=(null==I?void 0:I.length)?I:R;return!!e.find(e=>t.includes(e))}),A=j(w,[r,i,o,k,s,H,q.history&&u,g,q.html&&d,h,q.typographic&&O,U("Blockquote")&&e,U("Bold","strong")&&t,U("BulletList","bullet_list")&&l,U("Heading")&&n.configure({levels:q.headingLevels||[1,2,3,4,5]}),U("HorizontalRule","horizontal_rule")&&a,U("Italic","em")&&p,U("Link","link")&&c,U("BulletList","bullet_list","OrderedList","ordered_list")&&b,U("OrderedList","ordered_list")&&L,U("Strike","strikethrough")&&B,U("Subscript","sub")&&f,U("Superscript","sup")&&S,U("Underline")&&T,U("Table")&&m,U("Table")&&y,U("Table")&&_,U("Table")&&v].filter(Boolean)),C=new CustomEvent("prose-editor:ready",{detail:{editor:A,textarea:w},bubbles:!0});return w.dispatchEvent(C),A}w(e=>E(e),`[${z}]`),window.DjangoProseEditor={createEditor:E}; 2 | //# sourceMappingURL=default.js.map -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | The first step is to ensure that you have an activated virtualenv for your 5 | current project, using something like ``. .venv/bin/activate``. 6 | 7 | Install the package into your environment: 8 | 9 | .. code-block:: shell 10 | 11 | pip install django-prose-editor[sanitize] 12 | 13 | The ``sanitize`` extra automatically installs nh3 for the recommended HTML 14 | sanitization. It's strongly recommended to use this option for secure HTML processing. 15 | You only need to omit this if you plan to use a different HTML sanitizer. 16 | 17 | Add ``django_prose_editor`` to ``INSTALLED_APPS``: 18 | 19 | .. code-block:: python 20 | 21 | INSTALLED_APPS = [ 22 | # ... 23 | "django_prose_editor", 24 | ] 25 | 26 | Add the importmap by adding the ``js_asset.context_processors.importmap`` 27 | context processor and inserting ``{{ importmap }}`` somewhere in your base 28 | template, above all other scripts. 29 | 30 | Replace ``models.TextField`` with ``ProseEditorField`` where appropriate: 31 | 32 | .. code-block:: python 33 | 34 | from django_prose_editor.fields import ProseEditorField 35 | 36 | class Project(models.Model): 37 | description = ProseEditorField( 38 | extensions={"Bold": True, "Italic": True}, 39 | sanitize=True # Recommended to enable sanitization 40 | ) 41 | 42 | Note! No migrations will be generated when switching from and to 43 | ``models.TextField``. That's by design. Those migrations are mostly annoying. 44 | 45 | Besides the model field itself models using a ``ProseEditorField`` will have an 46 | easy way to create excerpts; the method for the example above would be 47 | ``get_description_excerpt``. 48 | -------------------------------------------------------------------------------- /tests/testapp/static/testapp/blue-bold.js: -------------------------------------------------------------------------------- 1 | import { Mark, mergeAttributes } from "django-prose-editor/editor" 2 | 3 | // Extend the bold mark to make it blue 4 | export const BlueBold = Mark.create({ 5 | name: "BlueBold", 6 | 7 | // Extend the default bold mark 8 | priority: 101, // Higher than the default bold priority 9 | 10 | // Add keyboard shortcuts 11 | addKeyboardShortcuts() { 12 | return { 13 | "Mod-Shift-b": () => this.editor.commands.toggleMark(this.name), 14 | } 15 | }, 16 | 17 | // Add input rules 18 | addInputRules() { 19 | // Match **blue:text** and apply BlueBold mark (more specific pattern) 20 | return [ 21 | { 22 | find: /\*\*blue:(.+?)\*\*/g, 23 | handler: ({ state, range, match }) => { 24 | const attributes = {} 25 | const { tr } = state 26 | 27 | // Delete the matching text 28 | tr.delete(range.from, range.to) 29 | 30 | // Add the text without the markers 31 | const text = match[1] 32 | tr.insertText(text, range.from) 33 | 34 | // Apply the BlueBold mark to the inserted text 35 | tr.addMark( 36 | range.from, 37 | range.from + text.length, 38 | this.type.create(attributes), 39 | ) 40 | 41 | return tr 42 | }, 43 | }, 44 | ] 45 | }, 46 | 47 | // Customize how it renders in the DOM 48 | renderHTML({ HTMLAttributes }) { 49 | return [ 50 | "strong", 51 | mergeAttributes(HTMLAttributes, { 52 | style: "color: blue;", 53 | class: "blue-bold-text", 54 | }), 55 | 0, 56 | ] 57 | }, 58 | 59 | addOptions() { 60 | return { 61 | HTMLAttributes: { 62 | class: "blue-bold-text", 63 | }, 64 | } 65 | }, 66 | }) 67 | -------------------------------------------------------------------------------- /docs/development.rst: -------------------------------------------------------------------------------- 1 | Development 2 | =========== 3 | 4 | For the best development experience: 5 | 6 | 1. Install django-prose-editor in editable mode in your project: 7 | 8 | .. code-block:: shell 9 | 10 | pip install -e /path/to/django-prose-editor 11 | 12 | 2. Run ``yarn && yarn dev`` in the django-prose-editor directory to watch for 13 | asset changes. 14 | 15 | When using ``yarn dev``: 16 | 17 | - The watcher will rebuild files automatically when you make changes. 18 | - Development mode provides faster builds for iteration. 19 | 20 | Both development and production builds: 21 | 22 | - Always generate minified CSS and JavaScript for optimal performance 23 | - Always include source maps to help identify exactly where in the source code 24 | an error occurs 25 | - Source maps are included in the distributed package to aid in debugging 26 | 27 | The build process ensures consistent output whether you're developing or 28 | building for production, with source maps always available for debugging 29 | purposes. 30 | 31 | Browser Testing with Playwright 32 | ------------------------------- 33 | 34 | This project uses tox to describe environments and Playwright for browser-based 35 | testing of the prose editor. Browser tests are run as a part of the normal tests 36 | so just use tox as you normally would. 37 | 38 | Code Style and Linting 39 | ---------------------- 40 | 41 | This project uses pre-commit hooks to enforce coding style guidelines. We use 42 | Ruff for Python linting and formatting, Biome for JavaScript/TypeScript linting 43 | and formatting and a few other hooks. 44 | 45 | To set up pre-commit using uv: 46 | 47 | .. code-block:: shell 48 | 49 | uv tool install pre-commit 50 | pre-commit install 51 | 52 | Pre-commit will automatically check your code for style issues when you commit 53 | changes. 54 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | 6 | @pytest.fixture 7 | def browser_context_args(browser_context_args): 8 | """Modify browser context arguments for tracing.""" 9 | return { 10 | **browser_context_args, 11 | "record_video_dir": os.path.join(os.getcwd(), "test-results/videos/"), 12 | "record_har_path": os.path.join(os.getcwd(), "test-results/har/", "test.har"), 13 | } 14 | 15 | 16 | @pytest.hookimpl(hookwrapper=True) 17 | def pytest_runtest_makereport(item, call): 18 | """Handle reporting and artifact generation.""" 19 | outcome = yield 20 | report = outcome.get_result() 21 | 22 | # Take screenshot of failed tests 23 | if report.when == "call" and report.failed: 24 | try: 25 | page = item.funcargs["page"] 26 | # Take screenshot and save it with test name 27 | screenshot_dir = os.path.join(os.getcwd(), "test-results/screenshots/") 28 | os.makedirs(screenshot_dir, exist_ok=True) 29 | screenshot_path = os.path.join(screenshot_dir, f"{item.name}_failed.png") 30 | page.screenshot(path=screenshot_path) 31 | # Save page HTML 32 | html_path = os.path.join(screenshot_dir, f"{item.name}_failed.html") 33 | with open(html_path, "w", encoding="utf-8") as f: 34 | f.write(page.content()) 35 | 36 | # Add to report 37 | report.extra = [ 38 | { 39 | "name": "Screenshot", 40 | "content": screenshot_path, 41 | "mime_type": "image/png", 42 | }, 43 | {"name": "HTML", "content": html_path, "mime_type": "text/html"}, 44 | ] 45 | except Exception as e: 46 | print(f"Failed to capture artifacts: {e}") 47 | -------------------------------------------------------------------------------- /django_prose_editor/static/django_prose_editor/configurable.js: -------------------------------------------------------------------------------- 1 | import*as e from"django-prose-editor/editor";function t(e,t,r,n,o,i,l){try{var u=e[i](l),a=u.value}catch(e){r(e);return}u.done?t(a):Promise.resolve(a).then(n,o)}function r(e){return function(){var r=this,n=arguments;return new Promise(function(o,i){var l=e.apply(r,n);function u(e){t(l,o,i,u,a,"next",e)}function a(e){t(l,o,i,u,a,"throw",e)}u(void 0)})}}let n="data-django-prose-editor-configurable",o=function(e){for(var t=1;t{if(i.has(e))return i.get(e);let t=import(e).then(e=>{Object.assign(o,e)}).catch(t=>{console.error(`Error loading extension module from ${e}:`,t),i.delete(e)});return i.set(e,t),t});yield Promise.all(e)})()));let c=[];for(let[e,t]of Object.entries(l.extensions)){let r=o[e];r&&("object"==typeof t?c.push(r.configure(t)):c.push(r))}return(0,e.createTextareaEditor)(t,c)})()})(t,a).then(e=>{if(e){let r=new CustomEvent("prose-editor:ready",{detail:{editor:e,textarea:t},bubbles:!0});t.dispatchEvent(r)}return l.delete(t),e}).catch(e=>(console.error("Error initializing prose editor:",e),l.delete(t),null));return l.set(t,c),c}(0,e.initializeEditors)(u,`[${n}]`);export{u as createEditor}; 2 | //# sourceMappingURL=configurable.js.map -------------------------------------------------------------------------------- /docs/forms.rst: -------------------------------------------------------------------------------- 1 | Usage outside the Django admin 2 | ============================== 3 | 4 | The prose editor can easily be used outside the Django admin. The form field 5 | respectively the widget includes the necessary CSS and JavaScript: 6 | 7 | .. code-block:: python 8 | 9 | from django_prose_editor.fields import ProseEditorFormField 10 | 11 | class Form(forms.Form): 12 | text = ProseEditorFormField( 13 | extensions={"Bold": True, "Italic": True}, 14 | sanitize=True # Recommended to enable sanitization 15 | ) 16 | 17 | Or maybe you want to use ``django_prose_editor.widgets.ProseEditorWidget``, but 18 | why make it more complicated than necessary. 19 | 20 | If you're rendering the form in a template you have to include the form media: 21 | 22 | .. code-block:: html+django 23 | 24 |
25 | {% csrf_token %} 26 | {{ form.media }} {# This is the important line! #} 27 | 28 | {{ form.errors }} {# Always makes sense #} 29 | {{ form.as_div }} 30 | 31 |
32 | 33 | Note that the form media isn't django-prose-editor specific, that's a Django 34 | feature. 35 | 36 | The django-prose-editor CSS uses the following CSS custom properties. 37 | 38 | * ``--prose-editor-background`` 39 | * ``--prose-editor-foreground`` 40 | * ``--prose-editor-border-color`` 41 | * ``--prose-editor-active-color`` 42 | * ``--prose-editor-disabled-color`` 43 | 44 | If you do not set them, they get their value from the following properties that 45 | are defined in the Django admin's CSS: 46 | 47 | * ``--border-color`` 48 | * ``--body-fg`` 49 | * ``--body-bg`` 50 | * ``--primary`` 51 | 52 | You should set these properties with appropriate values to use 53 | django-prose-editor outside the admin in your site. 54 | 55 | In addition, you may optionally set a ``--prose-editor-typographic`` property 56 | to control the color of typographic characters when shown. 57 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2024, Feinheit AG 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | 31 | -------------------------------------------------------------------------------- 32 | 33 | This software includes or depends on the following third-party libraries: 34 | 35 | ProseMirror (MIT License) 36 | Copyright (c) 2015-2017 by Marijn Haverbeke and others 37 | https://prosemirror.net/ 38 | 39 | Tiptap (MIT License) 40 | Copyright (c) 2024 Tiptap GmbH 41 | https://tiptap.dev/ 42 | 43 | Material Design Icons (Apache License 2.0) 44 | Copyright Google Inc. 45 | https://fonts.google.com/icons 46 | -------------------------------------------------------------------------------- /tests/testapp/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from js_asset import static_lazy 4 | 5 | from django_prose_editor.config import html_tags 6 | 7 | 8 | DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}} 9 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 10 | 11 | INSTALLED_APPS = [ 12 | "django.contrib.auth", 13 | "django.contrib.admin", 14 | "django.contrib.contenttypes", 15 | "django.contrib.sessions", 16 | "django.contrib.staticfiles", 17 | "django.contrib.messages", 18 | "django_prose_editor", 19 | "testapp", 20 | ] 21 | 22 | MEDIA_ROOT = "/media/" 23 | STATIC_URL = "/static/" 24 | BASEDIR = os.path.dirname(__file__) 25 | MEDIA_ROOT = os.path.join(BASEDIR, "media/") 26 | STATIC_ROOT = os.path.join(BASEDIR, "static/") 27 | SECRET_KEY = "supersikret" 28 | LOGIN_REDIRECT_URL = "/?login=1" 29 | ALLOWED_HOSTS = ["*"] 30 | 31 | ROOT_URLCONF = "testapp.urls" 32 | LANGUAGES = (("en", "English"), ("de", "German")) 33 | 34 | # No custom presets needed anymore 35 | DJANGO_PROSE_EDITOR_PRESETS = {} 36 | 37 | DJANGO_PROSE_EDITOR_EXTENSIONS = [ 38 | { 39 | "js": [static_lazy("testapp/blue-bold.js")], 40 | "extensions": { 41 | "BlueBold": html_tags( 42 | tags=["strong"], attributes={"strong": ["style", "class"]} 43 | ) 44 | }, 45 | }, 46 | ] 47 | 48 | 49 | TEMPLATES = [ 50 | { 51 | "BACKEND": "django.template.backends.django.DjangoTemplates", 52 | "DIRS": [], 53 | "APP_DIRS": True, 54 | "OPTIONS": { 55 | "context_processors": [ 56 | "django.template.context_processors.debug", 57 | "django.template.context_processors.request", 58 | "django.contrib.auth.context_processors.auth", 59 | "django.contrib.messages.context_processors.messages", 60 | ] 61 | }, 62 | } 63 | ] 64 | 65 | MIDDLEWARE = [ 66 | "django.middleware.security.SecurityMiddleware", 67 | "django.contrib.sessions.middleware.SessionMiddleware", 68 | "django.middleware.common.CommonMiddleware", 69 | "django.middleware.locale.LocaleMiddleware", 70 | "django.middleware.csrf.CsrfViewMiddleware", 71 | "django.contrib.auth.middleware.AuthenticationMiddleware", 72 | "django.contrib.messages.middleware.MessageMiddleware", 73 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 74 | ] 75 | -------------------------------------------------------------------------------- /tests/testapp/test_editor_destroy.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | from playwright.sync_api import expect 5 | 6 | 7 | # Set async unsafe for database operations 8 | os.environ.setdefault("DJANGO_ALLOW_ASYNC_UNSAFE", "true") 9 | 10 | 11 | @pytest.mark.django_db 12 | @pytest.mark.e2e 13 | def test_destroy_editor_direct_js(page, live_server): 14 | """Test editor destruction using the actual editor code.""" 15 | 16 | # Load the file using the file:// protocol 17 | page.goto(f"{live_server.url}/editor/") 18 | 19 | # Wait to ensure the page and scripts are loaded 20 | page.wait_for_load_state("networkidle") 21 | 22 | # Give additional time for ES modules to load 23 | page.wait_for_timeout(1000) 24 | 25 | # Check initial state - textarea should be visible 26 | expect(page.locator("#test-textarea")).to_have_count(1) 27 | 28 | # Try to call the setup function to initialize the editor 29 | page.evaluate("window.setupEditor()") 30 | 31 | # If setupEditor succeeded, check that the editor exists 32 | page.wait_for_selector(".prose-editor") 33 | editor_container = page.locator(".prose-editor") 34 | expect(editor_container).to_be_visible() 35 | 36 | # Check that textarea is inside the editor container 37 | textarea_in_editor = page.evaluate(""" 38 | () => { 39 | const textarea = document.getElementById('test-textarea'); 40 | const editorDiv = document.querySelector('.prose-editor'); 41 | return textarea && editorDiv && textarea.closest('.prose-editor') === editorDiv; 42 | } 43 | """) 44 | assert textarea_in_editor, "Textarea should be inside editor container" 45 | 46 | # Now call destroyEditor to test the destruction 47 | destroy_result = page.evaluate("window.destroyEditor()") 48 | assert destroy_result, "Editor destruction should succeed" 49 | 50 | # Check that the editor container is gone 51 | expect(page.locator(".prose-editor")).to_have_count(0) 52 | 53 | # Check that the textarea still exists and is back in the test-container 54 | expect(page.locator("#test-textarea")).to_have_count(1) 55 | 56 | # Check that the textarea is back in its original container 57 | textarea_in_container = page.evaluate(""" 58 | () => { 59 | const textarea = document.getElementById('test-textarea'); 60 | const container = document.getElementById('test-container'); 61 | return textarea.parentElement === container; 62 | } 63 | """) 64 | assert textarea_in_container, "Textarea should be back in original container" 65 | -------------------------------------------------------------------------------- /tests/testapp/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from django_prose_editor.fields import ProseEditorField 4 | from django_prose_editor.sanitized import SanitizedProseEditorField 5 | 6 | 7 | class ProseEditorModel(models.Model): 8 | description = ProseEditorField() 9 | 10 | def __str__(self): 11 | return self.description 12 | 13 | 14 | class SanitizedProseEditorModel(models.Model): 15 | description = SanitizedProseEditorField() 16 | 17 | def __str__(self): 18 | return self.description 19 | 20 | 21 | class TableProseEditorModel(models.Model): 22 | description = ProseEditorField( 23 | config={ 24 | "types": [ 25 | "Blockquote", 26 | "Bold", 27 | "BulletList", 28 | "Heading", 29 | "HorizontalRule", 30 | "Italic", 31 | "Link", 32 | "ListItem", 33 | "OrderedList", 34 | "Strike", 35 | "Subscript", 36 | "Superscript", 37 | "Underline", 38 | "Table", 39 | "TableRow", 40 | "TableHeader", 41 | "TableCell", 42 | ], 43 | "history": True, 44 | "html": True, 45 | "typographic": True, 46 | } 47 | ) 48 | 49 | def __str__(self): 50 | return self.description 51 | 52 | 53 | class ConfigurableProseEditorModel(models.Model): 54 | description = ProseEditorField( 55 | config={ 56 | "extensions": { 57 | "Bold": True, 58 | "Italic": True, 59 | "Table": True, 60 | "TableRow": True, 61 | "TableHeader": True, 62 | "TableCell": True, 63 | "Heading": {"levels": [1, 2, 3]}, # Limit to h1, h2, h3 64 | "BlueBold": True, 65 | "HTML": True, 66 | "CodeBlock": True, 67 | "BulletList": True, 68 | "OrderedList": True, 69 | "ListItem": True, 70 | "TextClass": {"cssClasses": ["highlight"]}, 71 | "NodeClass": { 72 | "cssClasses": { 73 | "paragraph": ["highlight"], 74 | "bold": ["emphasis", "important"], 75 | } 76 | }, 77 | } 78 | }, 79 | sanitize=True, 80 | ) 81 | 82 | def __str__(self): 83 | return self.description 84 | -------------------------------------------------------------------------------- /src/dialog.css: -------------------------------------------------------------------------------- 1 | .prose-editor-dialog { 2 | background: var(--_b); 3 | color: var(--_f); 4 | border: 1px solid var(--_r); 5 | border-radius: 4px; 6 | padding: 1em; 7 | min-width: 300px; 8 | max-width: min(800px, 90vw); 9 | } 10 | 11 | .prose-editor-dialog-title { 12 | font-size: 1.25em; 13 | font-weight: bold; 14 | margin: 0 !important; 15 | padding: 0 0 0.5em 0 !important; 16 | border-bottom: 1px solid var(--_r); 17 | } 18 | 19 | .prose-editor-dialog-field { 20 | padding: 0; 21 | margin: 1em 0; 22 | } 23 | 24 | .prose-editor-dialog-field:has(input[type="checkbox"]), 25 | .prose-editor-dialog-field:has(input[type="radio"]) { 26 | display: flex; 27 | flex-wrap: wrap; 28 | gap: 4px; 29 | } 30 | 31 | .prose-editor-dialog label { 32 | display: block; 33 | font-weight: bold; 34 | width: auto; 35 | min-width: 0; 36 | } 37 | 38 | .prose-editor-help { 39 | font-weight: normal; 40 | font-size: 80%; 41 | margin-left: 4px; 42 | } 43 | 44 | .prose-editor-dialog input:not([type="checkbox"], [type="radio"]), 45 | .prose-editor-dialog select, 46 | .prose-editor-dialog textarea, 47 | .prose-editor-grow-wrap::after { 48 | width: 100%; 49 | padding: 0.5em; 50 | border: 1px solid var(--_r); 51 | border-radius: 4px; 52 | margin-top: 0.5em; 53 | margin-bottom: 0; 54 | transition: 55 | border-color 0.2s, 56 | box-shadow 0.2s; 57 | font: inherit; 58 | } 59 | 60 | .prose-editor-dialog input:focus, 61 | .prose-editor-dialog select:focus, 62 | .prose-editor-dialog textarea:focus { 63 | outline: none; 64 | border-color: var(--_a); 65 | box-shadow: 0 0 0 2px rgba(121, 174, 200, 0.25); 66 | } 67 | 68 | .prose-editor-dialog input[type="number"] { 69 | width: 5em; 70 | } 71 | 72 | .prose-editor-dialog button { 73 | all: unset; 74 | cursor: pointer; 75 | padding: 0.5em 1em; 76 | transition: all 0.25s; 77 | background: var(--_b); 78 | color: var(--_f); 79 | border: 1px solid var(--_r); 80 | border-radius: 4px; 81 | text-align: center; 82 | display: inline-flex; 83 | position: relative; 84 | margin-right: 0.5em; 85 | margin-top: 0.5em; 86 | } 87 | 88 | .prose-editor-dialog button:hover { 89 | filter: brightness(1.1); 90 | background-color: rgba(0, 0, 0, 0.05); 91 | } 92 | 93 | .prose-editor-dialog button:active { 94 | filter: brightness(0.95); 95 | } 96 | 97 | .prose-editor-dialog button:focus { 98 | outline: 2px solid var(--_a); 99 | outline-offset: 2px; 100 | position: relative; 101 | z-index: 1; 102 | } 103 | -------------------------------------------------------------------------------- /src/editor.css: -------------------------------------------------------------------------------- 1 | .prose-editor *, 2 | .prose-editor *::before, 3 | .prose-editor *::after { 4 | box-sizing: inherit; 5 | } 6 | 7 | .prose-editor { 8 | box-sizing: border-box; 9 | position: relative; 10 | flex-grow: 1; 11 | } 12 | 13 | .prose-editor, 14 | .prose-editor-dialog { 15 | /* --body-bg, --body-fg, --border-color and --primary are used in the Django admin CSS */ 16 | --_b: var(--prose-editor-background, var(--body-bg, #fff)); 17 | --_f: var(--prose-editor-foreground, var(--body-fg, #333)); 18 | --_r: var(--prose-editor-border-color, var(--border-color, #ccc)); 19 | --_a: var(--prose-editor-active-color, var(--primary, #79aec8)); 20 | --_d: var(--prose-editor-disabled-color, var(--border-color, #ccc)); 21 | --_t: var(--prose-editor-typographic, var(--border-color, #ccc)); 22 | } 23 | 24 | [data-django-prose-editor] { 25 | opacity: 0; 26 | } 27 | 28 | .prose-editor > textarea { 29 | display: none !important; 30 | } 31 | 32 | .prose-editor .ProseMirror { 33 | padding: 0 6px; 34 | background: var(--_b); 35 | color: var(--_f); 36 | border: 1px solid var(--_r); 37 | border-radius: 4px; 38 | } 39 | 40 | /* content editor support */ 41 | label:empty:has(+ .prose-editor) { 42 | display: none; 43 | } 44 | 45 | .prose-editor-nbsp { 46 | background: var(--_t); 47 | box-shadow: 48 | 0 2px 0 0 var(--_t), 49 | 0 -2px 0 0 var(--_t); 50 | } 51 | 52 | .prose-editor-shy { 53 | background: var(--_t); 54 | box-shadow: 55 | 0 2px 0 1px var(--_t), 56 | 0 -2px 0 1px var(--_t); 57 | } 58 | 59 | /* Figure extension styling */ 60 | .ProseMirror .figure { 61 | display: flex; 62 | flex-direction: column; 63 | margin: 1.5rem 0; 64 | max-width: 100%; 65 | position: relative; 66 | } 67 | 68 | .ProseMirror .figure figcaption.figure-caption { 69 | padding: 0.5rem 0; 70 | color: #555; 71 | font-style: italic; 72 | font-size: 0.9rem; 73 | text-align: center; 74 | margin-top: 0.5rem; 75 | } 76 | 77 | .ProseMirror .figure img { 78 | max-width: 100%; 79 | height: auto; 80 | } 81 | 82 | /* Autogrowing textareas */ 83 | .prose-editor-grow-wrap { 84 | display: grid; 85 | } 86 | .prose-editor-grow-wrap::after { 87 | content: attr(data-value) " "; 88 | white-space: pre-wrap; 89 | visibility: hidden; 90 | } 91 | .prose-editor-grow-wrap > textarea { 92 | resize: none; 93 | overflow: hidden; 94 | } 95 | .prose-editor-grow-wrap > textarea, 96 | .prose-editor-grow-wrap::after { 97 | grid-area: 1 / 1 / 2 / 2; 98 | } 99 | 100 | /* Placeholder extension support */ 101 | .prose-editor p.is-editor-empty:first-child::before { 102 | opacity: 0.5; 103 | content: attr(data-placeholder); 104 | float: left; 105 | height: 0; 106 | pointer-events: none; 107 | } 108 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pm", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "dependencies": { 7 | "@rslib/core": "^0.18.4", 8 | "@tiptap/core": "^3.13.0", 9 | "@tiptap/extension-blockquote": "^3.13.0", 10 | "@tiptap/extension-bold": "^3.13.0", 11 | "@tiptap/extension-code": "^3.13.0", 12 | "@tiptap/extension-code-block": "^3.13.0", 13 | "@tiptap/extension-color": "^3.13.0", 14 | "@tiptap/extension-document": "^3.13.0", 15 | "@tiptap/extension-hard-break": "^3.13.0", 16 | "@tiptap/extension-heading": "^3.13.0", 17 | "@tiptap/extension-highlight": "^3.13.0", 18 | "@tiptap/extension-horizontal-rule": "^3.13.0", 19 | "@tiptap/extension-image": "^3.13.0", 20 | "@tiptap/extension-italic": "^3.13.0", 21 | "@tiptap/extension-link": "^3.13.0", 22 | "@tiptap/extension-list": "^3.13.0", 23 | "@tiptap/extension-paragraph": "^3.13.0", 24 | "@tiptap/extension-strike": "^3.13.0", 25 | "@tiptap/extension-subscript": "^3.13.0", 26 | "@tiptap/extension-superscript": "^3.13.0", 27 | "@tiptap/extension-table": "^3.13.0", 28 | "@tiptap/extension-text": "^3.13.0", 29 | "@tiptap/extension-text-align": "^3.13.0", 30 | "@tiptap/extension-text-style": "^3.13.0", 31 | "@tiptap/extension-underline": "^3.13.0", 32 | "@tiptap/extensions": "^3.13.0", 33 | "@tiptap/pm": "^3.13.0", 34 | "autoprefixer": "^10.4.22", 35 | "postcss": "^8.5.3", 36 | "prosemirror-dropcursor": "^1.8.2", 37 | "prosemirror-gapcursor": "^1.4.0", 38 | "prosemirror-history": "^1.5.0", 39 | "prosemirror-inputrules": "^1.5.1", 40 | "prosemirror-keymap": "^1.2.3", 41 | "prosemirror-menu": "^1.2.5", 42 | "prosemirror-model": "^1.25.4", 43 | "prosemirror-state": "^1.4.4", 44 | "prosemirror-tables": "^1.8.3", 45 | "prosemirror-transform": "^1.10.5", 46 | "prosemirror-view": "^1.41.4" 47 | }, 48 | "resolutions": { 49 | "prosemirror-changeset": "^2.3.0", 50 | "prosemirror-collab": "^1.3.1", 51 | "prosemirror-commands": "^1.6.2", 52 | "prosemirror-dropcursor": "^1.8.1", 53 | "prosemirror-gapcursor": "^1.3.2", 54 | "prosemirror-history": "^1.4.1", 55 | "prosemirror-inputrules": "^1.4.0", 56 | "prosemirror-keymap": "^1.2.2", 57 | "prosemirror-markdown": "^1.13.1", 58 | "prosemirror-menu": "^1.2.4", 59 | "prosemirror-model": "^1.24.1", 60 | "prosemirror-schema-basic": "^1.2.3", 61 | "prosemirror-schema-list": "^1.5.0", 62 | "prosemirror-state": "^1.4.3", 63 | "prosemirror-tables": "^1.6.4", 64 | "prosemirror-trailing-node": "^3.0.0", 65 | "prosemirror-transform": "^1.10.2", 66 | "prosemirror-view": "^1.38.1" 67 | }, 68 | "scripts": { 69 | "dev": "rslib build --watch", 70 | "prod": "NODE_ENV=production rslib build" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /rslib.config.mjs: -------------------------------------------------------------------------------- 1 | import { promises as fs } from "node:fs" 2 | import { createRequire } from "node:module" 3 | import { join } from "node:path" 4 | import { defineConfig } from "@rslib/core" 5 | 6 | const require = createRequire(import.meta.url) 7 | const isProduction = process.env.NODE_ENV === "production" 8 | 9 | async function removeZeroSizedFiles(distPath) { 10 | try { 11 | const files = await fs.readdir(distPath, { withFileTypes: true }) 12 | 13 | for (const file of files) { 14 | if (file.isFile()) { 15 | const filePath = join(distPath, file.name) 16 | const stats = await fs.stat(filePath) 17 | if (stats.size === 0) { 18 | await fs.unlink(filePath) 19 | console.log(`Removed zero-sized file: ${filePath}`) 20 | } 21 | } 22 | } 23 | } catch (error) { 24 | console.error(`Error removing zero-sized files: ${error.message}`) 25 | } 26 | } 27 | 28 | const commonConfig = { 29 | autoExternal: false, 30 | bundle: true, 31 | format: "esm", 32 | syntax: "es6", 33 | output: { 34 | distPath: { 35 | root: "django_prose_editor/static/django_prose_editor/", 36 | css: "", 37 | js: "", 38 | font: "", 39 | }, 40 | filename: { 41 | js: "[name].js", 42 | css: "[name].css", 43 | }, 44 | sourceMap: true, 45 | minify: isProduction, 46 | target: "web", 47 | }, 48 | } 49 | 50 | export default defineConfig({ 51 | lib: [ 52 | { 53 | ...commonConfig, 54 | source: { 55 | entry: { 56 | editor: "./src/editor.js", 57 | overrides: "./src/overrides.css", 58 | "material-icons": "./src/material-icons.css", 59 | }, 60 | }, 61 | }, 62 | // Editor presets 63 | { 64 | ...commonConfig, 65 | source: { 66 | entry: { 67 | default: "./src/default.js", 68 | configurable: "./src/configurable.js", 69 | }, 70 | }, 71 | output: { 72 | ...commonConfig.output, 73 | externals: { 74 | "django-prose-editor/editor": "module django-prose-editor/editor", 75 | }, 76 | chunkLoading: "import", 77 | }, 78 | }, 79 | ], 80 | tools: { 81 | postcss: (opts) => { 82 | opts.postcssOptions.plugins = [require("autoprefixer")()] 83 | }, 84 | rspack: { 85 | plugins: [ 86 | { 87 | apply: (compiler) => { 88 | compiler.hooks.afterDone.tap( 89 | "RemoveZeroSizedFilesPlugin", 90 | async () => { 91 | await removeZeroSizedFiles(commonConfig.output.distPath.root) 92 | }, 93 | ) 94 | }, 95 | }, 96 | ], 97 | }, 98 | }, 99 | }) 100 | -------------------------------------------------------------------------------- /docs/legacy.rst: -------------------------------------------------------------------------------- 1 | Legacy Configuration 2 | ==================== 3 | 4 | Old Approach 5 | ------------ 6 | 7 | The ``SanitizedProseEditorField`` is a legacy class that automatically enables 8 | sanitization but uses a broad sanitization approach that allows most HTML elements. 9 | While secure from XSS, it's not tailored to your specific extensions: 10 | 11 | .. code-block:: python 12 | 13 | from django_prose_editor.sanitized import SanitizedProseEditorField 14 | 15 | description = SanitizedProseEditorField() # Not recommended 16 | 17 | Instead, it's strongly recommended to use the extension-aware sanitization approach: 18 | 19 | .. code-block:: python 20 | 21 | from django_prose_editor.fields import ProseEditorField 22 | 23 | description = ProseEditorField( 24 | extensions={"Bold": True, "Italic": True, "Link": True}, 25 | sanitize=True # Uses sanitization rules specific to these extensions 26 | ) 27 | 28 | This provides better security by only allowing the specific HTML elements and attributes 29 | needed by your enabled extensions. 30 | 31 | You can also pass your own callable receiving and returning HTML 32 | using the ``sanitize`` keyword argument if you need custom sanitization logic. 33 | 34 | Simple Customization with Config (Deprecated) 35 | --------------------------------------------- 36 | 37 | For basic customization, you can use the ``config`` parameter to specify which 38 | extensions should be enabled. This was the only available way to configure the 39 | prose editor up to version 0.9. It's now deprecated because using the 40 | ``extensions`` mechanism documented above is much more powerful, integrated and 41 | secure. 42 | 43 | This legacy approach doesn't support sanitization at all. 44 | 45 | .. code-block:: python 46 | 47 | from django_prose_editor.fields import ProseEditorField 48 | 49 | class Article(models.Model): 50 | content = ProseEditorField( 51 | config={ 52 | "types": [ 53 | "Bold", "Italic", "Strike", "BulletList", "OrderedList", 54 | "HorizontalRule", "Link", 55 | ], 56 | "history": True, 57 | "html": True, 58 | "typographic": True, 59 | } 60 | ) 61 | 62 | All extension names now use the Tiptap names (e.g., ``Bold``, ``Italic``, 63 | ``BulletList``, ``HorizontalRule``). For backward compatibility, the following legacy 64 | ProseMirror-style names are still supported: 65 | 66 | * Legacy node names: ``bullet_list`` → ``BulletList``, ``ordered_list`` → 67 | ``OrderedList``, ``horizontal_rule`` → ``HorizontalRule`` 68 | * Legacy mark names: ``strong`` → ``Bold``, ``em`` → ``Italic``, 69 | ``strikethrough`` → ``Strike``, ``sub`` → ``Subscript``, ``sup`` → ``Superscript``, 70 | ``link`` → ``Link`` 71 | 72 | Note that when using the legacy format, lists and tables automatically include 73 | the extensions they depend on. 74 | -------------------------------------------------------------------------------- /src/typographic.js: -------------------------------------------------------------------------------- 1 | // Plugin which shows typographic characters (currently only non-breaking spaces) 2 | 3 | import { Extension } from "@tiptap/core" 4 | 5 | import { Plugin } from "@tiptap/pm/state" 6 | import { Decoration, DecorationSet } from "@tiptap/pm/view" 7 | 8 | export const Typographic = Extension.create({ 9 | name: "typographic", 10 | 11 | addProseMirrorPlugins() { 12 | return [typographicPlugin] 13 | }, 14 | }) 15 | 16 | // https://discuss.prosemirror.net/t/efficiently-finding-changed-nodes/4280/5 17 | // Helper for iterating through the nodes in a document that changed 18 | // compared to the given previous document. Useful for avoiding 19 | // duplicate work on each transaction. 20 | function changedDescendants(old, cur, offset, f) { 21 | const oldSize = old.childCount 22 | const curSize = cur.childCount 23 | outer: for (let i = 0, j = 0; i < curSize; i++) { 24 | const child = cur.child(i) 25 | for (let scan = j, e = Math.min(oldSize, i + 3); scan < e; scan++) { 26 | if (old.child(scan) === child) { 27 | j = scan + 1 28 | offset += child.nodeSize 29 | continue outer 30 | } 31 | } 32 | f(child, offset) 33 | if (j < oldSize && old.child(j).sameMarkup(child)) 34 | changedDescendants(old.child(j), child, offset + 1, f) 35 | else child.nodesBetween(0, child.content.size, f, offset + 1) 36 | offset += child.nodeSize 37 | } 38 | } 39 | 40 | const classes = { 41 | "\u00A0": "prose-editor-nbsp", 42 | "\u00AD": "prose-editor-shy", 43 | } 44 | 45 | const typographicDecorationsForNode = (node, position) => { 46 | const decorations = [] 47 | if (node.text) { 48 | for (const match of node.text.matchAll(/(\u00A0|\u00AD)/g)) { 49 | const from = position + (match.index || 0) 50 | decorations.push( 51 | Decoration.inline(from, from + 1, { 52 | class: classes[match[1]], 53 | }), 54 | ) 55 | } 56 | } 57 | return decorations 58 | } 59 | 60 | const typographicDecorations = (doc) => { 61 | const decorations = [] 62 | doc.descendants((node, position) => { 63 | decorations.push(typographicDecorationsForNode(node, position)) 64 | }) 65 | return DecorationSet.create(doc, decorations.flat()) 66 | } 67 | 68 | const typographicPlugin = new Plugin({ 69 | state: { 70 | init(_, { doc }) { 71 | return typographicDecorations(doc) 72 | }, 73 | apply(tr, set, oldState) { 74 | // I fear that's not very performant. Maybe improve this "later". 75 | // return tr.docChanged ? typographicDecorations(tr.doc) : set 76 | 77 | let newSet = set.map(tr.mapping, tr.doc) 78 | changedDescendants(oldState.doc, tr.doc, 0, (node, offset) => { 79 | // First, remove our inline decorations for the current node 80 | newSet = newSet.remove(newSet.find(offset, offset + node.content.size)) 81 | // Then, add decorations (including the new content) 82 | newSet = newSet.add(tr.doc, typographicDecorationsForNode(node, offset)) 83 | }) 84 | 85 | return newSet 86 | }, 87 | }, 88 | props: { 89 | decorations(state) { 90 | return typographicPlugin.getState(state) 91 | }, 92 | }, 93 | }) 94 | -------------------------------------------------------------------------------- /src/fullscreen.js: -------------------------------------------------------------------------------- 1 | import { Extension } from "@tiptap/core" 2 | 3 | /** 4 | * Fullscreen extension for the prose editor 5 | * Adds a button to toggle fullscreen mode, expanding the editor to cover the entire viewport 6 | */ 7 | export const Fullscreen = Extension.create({ 8 | name: "fullscreen", 9 | 10 | addOptions() { 11 | return { 12 | // CSS class applied to the editor container when in fullscreen mode 13 | fullscreenClass: "fullscreen", 14 | } 15 | }, 16 | 17 | addCommands() { 18 | return { 19 | toggleFullscreen: 20 | () => 21 | ({ editor }) => { 22 | const isFullscreen = editor.storage.fullscreen.fullscreen 23 | 24 | // Toggle fullscreen state 25 | editor.storage.fullscreen.fullscreen = !isFullscreen 26 | 27 | // Apply or remove the fullscreen class to the editor container 28 | const editorContainer = 29 | editor.options.element.closest(".prose-editor") 30 | 31 | // Force the menu to update after state change 32 | setTimeout(() => { 33 | editor.view.dispatch(editor.view.state.tr) 34 | }, 0) 35 | 36 | if (editor.storage.fullscreen.fullscreen) { 37 | editorContainer.classList.add(this.options.fullscreenClass) 38 | // Store the scroll position 39 | editor.storage.fullscreen.scrollPosition = window.scrollY 40 | // Prevent body scrolling 41 | document.body.style.overflow = "hidden" 42 | // Focus the editor after going fullscreen 43 | editor.commands.focus() 44 | 45 | // Ensure our floating menubar is reset when entering fullscreen 46 | const menubar = editorContainer.querySelector(".prose-menubar") 47 | if (menubar) { 48 | menubar.classList.remove("prose-menubar--floating") 49 | menubar.style.width = "" 50 | menubar.style.left = "" 51 | menubar.style.top = "" 52 | } 53 | // Hide the placeholder 54 | const placeholder = editorContainer.querySelector( 55 | ".prose-menubar-placeholder", 56 | ) 57 | if (placeholder) { 58 | placeholder.classList.remove("prose-menubar-placeholder--active") 59 | } 60 | } else { 61 | editorContainer.classList.remove(this.options.fullscreenClass) 62 | // Restore body scrolling 63 | document.body.style.overflow = "" 64 | // Restore scroll position 65 | window.scrollTo(0, editor.storage.fullscreen.scrollPosition || 0) 66 | } 67 | 68 | return true 69 | }, 70 | } 71 | }, 72 | 73 | addStorage() { 74 | return { 75 | fullscreen: false, 76 | scrollPosition: 0, 77 | } 78 | }, 79 | 80 | addKeyboardShortcuts() { 81 | return { 82 | // Add ESC shortcut to exit fullscreen mode 83 | Escape: () => { 84 | if (this.editor.storage.fullscreen.fullscreen) { 85 | return this.editor.commands.toggleFullscreen() 86 | } 87 | return false 88 | }, 89 | // Add F11 shortcut to toggle fullscreen mode 90 | F11: () => { 91 | return this.editor.commands.toggleFullscreen() 92 | }, 93 | } 94 | }, 95 | }) 96 | -------------------------------------------------------------------------------- /src/textClass.js: -------------------------------------------------------------------------------- 1 | import { Mark, mergeAttributes } from "@tiptap/core" 2 | import { crel } from "./utils.js" 3 | 4 | const cssClass = (c) => (typeof c === "string" ? { className: c, title: c } : c) 5 | const isValidClass = (cssClasses, className) => 6 | cssClasses.find((c) => cssClass(c).className === className) 7 | 8 | export const TextClass = Mark.create({ 9 | name: "textClass", 10 | priority: 101, // Slightly higher priority so that e.g. strong doesn't split text class marks 11 | 12 | addOptions() { 13 | return { 14 | HTMLAttributes: {}, 15 | cssClasses: [], 16 | } 17 | }, 18 | 19 | addAttributes() { 20 | return { 21 | class: { 22 | default: null, 23 | parseHTML: (element) => { 24 | const className = element.className?.trim() 25 | if (!className) return null 26 | 27 | return isValidClass(this.options.cssClasses, className) 28 | ? className 29 | : null 30 | }, 31 | renderHTML: (attributes) => { 32 | if (!attributes.class) { 33 | return {} 34 | } 35 | 36 | return { 37 | class: attributes.class, 38 | } 39 | }, 40 | }, 41 | } 42 | }, 43 | 44 | parseHTML() { 45 | return [ 46 | { 47 | tag: "span", 48 | consuming: false, 49 | getAttrs: (element) => { 50 | const className = element.className?.trim() 51 | if (!className) return false 52 | 53 | return isValidClass(this.options.cssClasses, className) ? {} : false 54 | }, 55 | }, 56 | ] 57 | }, 58 | 59 | renderHTML({ HTMLAttributes }) { 60 | return [ 61 | "span", 62 | mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 63 | 0, 64 | ] 65 | }, 66 | 67 | addCommands() { 68 | return { 69 | setTextClass: 70 | (className) => 71 | ({ commands }) => { 72 | if (!isValidClass(this.options.cssClasses, className)) { 73 | return false 74 | } 75 | return commands.setMark(this.name, { class: className }) 76 | }, 77 | unsetTextClass: 78 | () => 79 | ({ commands }) => { 80 | return commands.unsetMark(this.name, { extendEmptyMarkRange: true }) 81 | }, 82 | } 83 | }, 84 | 85 | addMenuItems({ buttons, menu }) { 86 | if (this.options.cssClasses.length === 0) { 87 | return 88 | } 89 | 90 | for (const { className, title } of [ 91 | "default", 92 | ...this.options.cssClasses, 93 | ].map(cssClass)) { 94 | menu.defineItem({ 95 | name: `${this.name}:${className}`, 96 | groups: this.name, 97 | button: buttons.text(title), 98 | option: crel("p", { className, textContent: title }), 99 | active(editor) { 100 | return className === "default" 101 | ? !editor.isActive("textClass") 102 | : editor.isActive("textClass", { class: className }) 103 | }, 104 | command(editor) { 105 | if (className === "default") { 106 | editor.chain().focus().unsetTextClass().run() 107 | } else { 108 | editor.chain().focus().setTextClass(className).run() 109 | } 110 | }, 111 | }) 112 | } 113 | }, 114 | }) 115 | -------------------------------------------------------------------------------- /src/default.js: -------------------------------------------------------------------------------- 1 | import { 2 | Blockquote, 3 | Bold, 4 | BulletList, 5 | createTextareaEditor, 6 | Document, 7 | Dropcursor, 8 | Gapcursor, 9 | HardBreak, 10 | Heading, 11 | History, 12 | HorizontalRule, 13 | HTML, 14 | Italic, 15 | initializeEditors, 16 | Link, 17 | ListItem, 18 | Menu, 19 | NoSpellCheck, 20 | OrderedList, 21 | Paragraph, 22 | Strike, 23 | Subscript, 24 | Superscript, 25 | Table, 26 | TableCell, 27 | TableHeader, 28 | TableRow, 29 | Text, 30 | Typographic, 31 | Underline, 32 | } from "django-prose-editor/editor" 33 | 34 | const marker = "data-django-prose-editor-default" 35 | 36 | function createEditor(textarea, config = null) { 37 | if (textarea.closest(".prose-editor")) return 38 | 39 | if (!config) { 40 | config = JSON.parse(textarea.getAttribute(marker)) 41 | } 42 | 43 | // Default extension types (table explicitly excluded) 44 | const DEFAULT_TYPES = [ 45 | "Blockquote", 46 | "Bold", 47 | "BulletList", 48 | "Heading", 49 | "HorizontalRule", 50 | "Italic", 51 | "Link", 52 | "OrderedList", 53 | "Strike", 54 | "Subscript", 55 | "Superscript", 56 | "Underline", 57 | ] 58 | 59 | const createIsTypeEnabled = 60 | (enabledTypes) => 61 | (...types) => { 62 | // If no types defined, use the defaults 63 | const typesToCheck = enabledTypes?.length ? enabledTypes : DEFAULT_TYPES 64 | return !!types.find((t) => typesToCheck.includes(t)) 65 | } 66 | const isTypeEnabled = createIsTypeEnabled(config.types) 67 | 68 | const extensions = [ 69 | Document, 70 | Dropcursor, 71 | Gapcursor, 72 | Paragraph, 73 | HardBreak, 74 | Text, 75 | config.history && History, 76 | Menu, 77 | config.html && HTML, 78 | NoSpellCheck, 79 | config.typographic && Typographic, 80 | // Nodes and marks 81 | isTypeEnabled("Blockquote") && Blockquote, 82 | isTypeEnabled("Bold", "strong") && Bold, 83 | isTypeEnabled("BulletList", "bullet_list") && BulletList, 84 | isTypeEnabled("Heading") && 85 | Heading.configure({ levels: config.headingLevels || [1, 2, 3, 4, 5] }), 86 | isTypeEnabled("HorizontalRule", "horizontal_rule") && HorizontalRule, 87 | isTypeEnabled("Italic", "em") && Italic, 88 | isTypeEnabled("Link", "link") && Link, 89 | isTypeEnabled("BulletList", "bullet_list", "OrderedList", "ordered_list") && 90 | ListItem, 91 | isTypeEnabled("OrderedList", "ordered_list") && OrderedList, 92 | isTypeEnabled("Strike", "strikethrough") && Strike, 93 | isTypeEnabled("Subscript", "sub") && Subscript, 94 | isTypeEnabled("Superscript", "sup") && Superscript, 95 | isTypeEnabled("Underline") && Underline, 96 | // Table support 97 | isTypeEnabled("Table") && Table, 98 | isTypeEnabled("Table") && TableRow, 99 | isTypeEnabled("Table") && TableHeader, 100 | isTypeEnabled("Table") && TableCell, 101 | ].filter(Boolean) 102 | 103 | const editor = createTextareaEditor(textarea, extensions) 104 | const event = new CustomEvent("prose-editor:ready", { 105 | detail: { editor, textarea }, 106 | bubbles: true, 107 | }) 108 | textarea.dispatchEvent(event) 109 | return editor 110 | } 111 | 112 | initializeEditors((textarea) => { 113 | return createEditor(textarea) 114 | }, `[${marker}]`) 115 | 116 | // Backwards compatibility shim for django-prose-editor < 0.10 117 | window.DjangoProseEditor = { createEditor } 118 | -------------------------------------------------------------------------------- /django_prose_editor/locale/pl/LC_MESSAGES/djangojs.po: -------------------------------------------------------------------------------- 1 | # Polish translations for django-prose-editor. 2 | # Copyright (C) 2024 Matthias Kestenholz 3 | # This file is distributed under the same license as the django-prose-editor package. 4 | # Karol Gunia , 2025. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: django-prose-editor\n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2025-09-15 22:06+0200\n" 11 | "PO-Revision-Date: 2025-12-05 14:30+0100\n" 12 | "Last-Translator: Karol Gunia \n" 13 | "Language-Team: Polish\n" 14 | "Language: pl\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" 19 | 20 | #: src/figure.js:134 21 | msgid "Image URL" 22 | msgstr "Adres URL obrazu" 23 | 24 | #: src/figure.js:140 25 | msgid "Alternative Text" 26 | msgstr "Tekst alternatywny" 27 | 28 | #: src/figure.js:144 29 | msgid "Caption" 30 | msgstr "Podpis" 31 | 32 | #: src/figure.js:151 33 | msgid "Edit Figure" 34 | msgstr "Edytuj obraz" 35 | 36 | #: src/figure.js:152 37 | msgid "Insert Figure" 38 | msgstr "Wstaw obraz" 39 | 40 | #: src/figure.js:153 src/orderedList.js:63 src/utils.js:95 41 | msgid "Update" 42 | msgstr "Aktualizuj" 43 | 44 | #: src/figure.js:153 45 | msgid "Insert" 46 | msgstr "Wstaw" 47 | 48 | #: src/html.js:11 49 | msgid "" 50 | "The HTML contents of the editor. Note that the allowed HTML is restricted by " 51 | "the editor schema." 52 | msgstr "" 53 | "Zawartość HTML edytora. Kod HTML jest ograniczony przez schemat edytora." 54 | 55 | #: src/html.js:17 56 | msgid "Edit HTML" 57 | msgstr "Edytuj HTML" 58 | 59 | #: src/html.js:20 60 | msgid "Prettify" 61 | msgstr "Upiększ" 62 | 63 | #: src/link.js:9 64 | msgid "URL" 65 | msgstr "URL" 66 | 67 | #: src/link.js:13 68 | msgid "Title" 69 | msgstr "Tytuł" 70 | 71 | #: src/link.js:20 72 | msgid "Open in new window" 73 | msgstr "Otwórz w nowym oknie" 74 | 75 | #: src/link.js:24 76 | msgid "Edit Link" 77 | msgstr "Edytuj link" 78 | 79 | #: src/menu.js:478 src/orderedList.js:62 80 | msgid "List properties" 81 | msgstr "Właściwości listy" 82 | 83 | #: src/menu.js:641 84 | msgid "Toggle fullscreen" 85 | msgstr "Przełącz tryb pełnoekranowy" 86 | 87 | #: src/orderedList.js:10 88 | msgid "Decimal numbers" 89 | msgstr "Liczby dziesiętne" 90 | 91 | #: src/orderedList.js:15 92 | msgid "Lowercase letters" 93 | msgstr "Małe litery" 94 | 95 | #: src/orderedList.js:20 96 | msgid "Uppercase letters" 97 | msgstr "Wielkie litery" 98 | 99 | #: src/orderedList.js:25 100 | msgid "Lowercase Roman numerals" 101 | msgstr "Małe cyfry rzymskie" 102 | 103 | #: src/orderedList.js:30 104 | msgid "Uppercase Roman numerals" 105 | msgstr "Duże cyfry rzymskie" 106 | 107 | #: src/orderedList.js:50 108 | msgid "Start at" 109 | msgstr "Zacznij od" 110 | 111 | #: src/orderedList.js:56 112 | msgid "List type" 113 | msgstr "Typ listy" 114 | 115 | #: src/table.js:9 116 | msgid "Rows" 117 | msgstr "Wiersze" 118 | 119 | #: src/table.js:17 120 | msgid "Columns" 121 | msgstr "Kolumny" 122 | 123 | #: src/table.js:24 124 | msgid "Include header row" 125 | msgstr "Dołącz wiersz nagłówka" 126 | 127 | #: src/table.js:30 128 | msgid "Table Properties" 129 | msgstr "Właściwości tabeli" 130 | 131 | #: src/table.js:31 132 | msgid "Insert Table" 133 | msgstr "Wstaw tabelę" 134 | 135 | #: src/utils.js:100 136 | msgid "Cancel" 137 | msgstr "Anuluj" 138 | -------------------------------------------------------------------------------- /docs/presets.rst: -------------------------------------------------------------------------------- 1 | Presets 2 | ======= 3 | 4 | For advanced customization, you can create custom presets by adding 5 | additional assets to load: 6 | 7 | .. code-block:: python 8 | 9 | from js_asset import JS 10 | 11 | DJANGO_PROSE_EDITOR_PRESETS = { 12 | "announcements": [ 13 | JS("prose-editors/announcements.js", {"type": "module"}), 14 | ], 15 | } 16 | 17 | The preset can be selected when instantiating the field: 18 | 19 | .. code-block:: python 20 | 21 | text = ProseEditorField( 22 | _("text"), 23 | preset="announcements", 24 | sanitize=False, # The default configuration may be too restrictive. 25 | ) 26 | 27 | The editor uses ES modules and importmaps; you can import extensions and 28 | utilities from the `django-prose-editor/editor` module. The importmap support 29 | is provided by `django-js-asset 30 | `_, check it's README to learn 31 | more. 32 | 33 | Here's the example: 34 | 35 | .. code-block:: javascript 36 | 37 | import { 38 | // Always recommended: 39 | Document, Dropcursor, Gapcursor, Paragraph, HardBreak, Text, 40 | 41 | // Add support for a few marks: 42 | Bold, Italic, Subscript, Superscript, Link, 43 | 44 | // A menu is always nice: 45 | Menu, 46 | 47 | // Helper which knows how to attach a prose editor to a textarea: 48 | createTextareaEditor, 49 | 50 | // Helper which runs the initialization on page load and when 51 | // new textareas are added through Django admin inlines: 52 | initializeEditors, 53 | } from "django-prose-editor/editor" 54 | 55 | 56 | // "announcements" is the name of the preset. 57 | const marker = "data-django-prose-editor-announcements" 58 | 59 | function createEditor(textarea) { 60 | if (textarea.closest(".prose-editor")) return 61 | const config = JSON.parse(textarea.getAttribute(marker)) 62 | 63 | const extensions = [ 64 | Document, Dropcursor, Gapcursor, Paragraph, HardBreak, Text, 65 | 66 | Bold, Italic, Subscript, Superscript, Link, 67 | 68 | Menu, 69 | ] 70 | 71 | return createTextareaEditor(textarea, extensions) 72 | } 73 | 74 | initializeEditors(createEditor, `[${marker}]`) 75 | 76 | JavaScript Events 77 | ----------------- 78 | 79 | The configurable editor fires custom events that you can listen for in your frontend code: 80 | 81 | **prose-editor:ready** 82 | 83 | This event is fired when an editor is fully initialized and ready to use. It's dispatched on the textarea element and bubbles up the DOM. 84 | 85 | .. code-block:: javascript 86 | 87 | // Listen for editor initialization 88 | document.addEventListener('prose-editor:ready', (event) => { 89 | // Access the editor instance and the textarea 90 | const { editor, textarea } = event.detail; 91 | 92 | // Example: Focus the editor when it's ready 93 | editor.commands.focus(); 94 | 95 | // Example: Get the textarea's ID for reference 96 | console.log(`Editor ready for ${textarea.id}`); 97 | }); 98 | 99 | The event provides an object in the `detail` property with: 100 | - `editor`: The initialized editor instance with full access to Tiptap commands and API 101 | - `textarea`: The original textarea element that was enhanced with the editor 102 | 103 | This is useful when you need to interact with editors programmatically or initialize other components that depend on the editor being fully loaded. 104 | -------------------------------------------------------------------------------- /django_prose_editor/locale/de/LC_MESSAGES/djangojs.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2025-09-15 22:06+0200\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 20 | 21 | #: src/figure.js:134 22 | msgid "Image URL" 23 | msgstr "Bild-URL" 24 | 25 | #: src/figure.js:140 26 | msgid "Alternative Text" 27 | msgstr "Alternativer Text" 28 | 29 | #: src/figure.js:144 30 | msgid "Caption" 31 | msgstr "Legende" 32 | 33 | #: src/figure.js:151 34 | msgid "Edit Figure" 35 | msgstr "Bild bearbeiten" 36 | 37 | #: src/figure.js:152 38 | msgid "Insert Figure" 39 | msgstr "Bild einfügen" 40 | 41 | #: src/figure.js:153 src/orderedList.js:63 src/utils.js:95 42 | msgid "Update" 43 | msgstr "Aktualisieren" 44 | 45 | #: src/figure.js:153 46 | msgid "Insert" 47 | msgstr "Einfügen" 48 | 49 | #: src/html.js:11 50 | msgid "" 51 | "The HTML contents of the editor. Note that the allowed HTML is restricted by " 52 | "the editor schema." 53 | msgstr "" 54 | "Der HTML-Inhalt des Editors. Das HTML wird immer noch durch das Editor-Schema eingeschränkt." 55 | 56 | #: src/html.js:17 57 | msgid "Edit HTML" 58 | msgstr "HTML bearbeiten" 59 | 60 | #: src/html.js:20 61 | msgid "Prettify" 62 | msgstr "Verschönern" 63 | 64 | #: src/link.js:9 65 | msgid "URL" 66 | msgstr "URL" 67 | 68 | #: src/link.js:13 69 | msgid "Title" 70 | msgstr "Titel" 71 | 72 | #: src/link.js:20 73 | msgid "Open in new window" 74 | msgstr "In neuem Fenster öffnen" 75 | 76 | #: src/link.js:24 77 | msgid "Edit Link" 78 | msgstr "Link bearbeiten" 79 | 80 | #: src/menu.js:478 src/orderedList.js:62 81 | msgid "List properties" 82 | msgstr "Listen-Einstellungen" 83 | 84 | #: src/menu.js:641 85 | msgid "Toggle fullscreen" 86 | msgstr "Vollbild umschalten" 87 | 88 | #: src/orderedList.js:10 89 | msgid "Decimal numbers" 90 | msgstr "Dezimalzahlen" 91 | 92 | #: src/orderedList.js:15 93 | msgid "Lowercase letters" 94 | msgstr "Kleinbuchstaben" 95 | 96 | #: src/orderedList.js:20 97 | msgid "Uppercase letters" 98 | msgstr "Grossbuchstaben" 99 | 100 | #: src/orderedList.js:25 101 | msgid "Lowercase Roman numerals" 102 | msgstr "Kleine römische Zahlen" 103 | 104 | #: src/orderedList.js:30 105 | msgid "Uppercase Roman numerals" 106 | msgstr "Grosse römische Zahlen" 107 | 108 | #: src/orderedList.js:50 109 | msgid "Start at" 110 | msgstr "Starten mit" 111 | 112 | #: src/orderedList.js:56 113 | msgid "List type" 114 | msgstr "Listentyp" 115 | 116 | #: src/table.js:9 117 | msgid "Rows" 118 | msgstr "Zeilen" 119 | 120 | #: src/table.js:17 121 | msgid "Columns" 122 | msgstr "Spalten" 123 | 124 | #: src/table.js:24 125 | msgid "Include header row" 126 | msgstr "Inklusive Kopfzeile" 127 | 128 | #: src/table.js:30 129 | msgid "Table Properties" 130 | msgstr "Tabellen-Einstellungen" 131 | 132 | #: src/table.js:31 133 | msgid "Insert Table" 134 | msgstr "Tabelle einfügen" 135 | 136 | #: src/utils.js:100 137 | msgid "Cancel" 138 | msgstr "Abbrechen" 139 | 140 | #~ msgid "List Properties" 141 | #~ msgstr "Listen-Einstellungen" 142 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "hatchling.build" 3 | 4 | requires = [ 5 | "hatchling", 6 | ] 7 | 8 | [project] 9 | name = "django-prose-editor" 10 | description = "Prose editor for the Django admin based on ProseMirror" 11 | readme = "README.rst" 12 | license = { text = "BSD-3-Clause" } 13 | authors = [ 14 | { name = "Matthias Kestenholz", email = "mk@feinheit.ch" }, 15 | ] 16 | requires-python = ">=3.10" 17 | classifiers = [ 18 | "Environment :: Web Environment", 19 | "Framework :: Django", 20 | "Framework :: Django :: 4.2", 21 | "Intended Audience :: Developers", 22 | "License :: OSI Approved :: BSD License", 23 | "Operating System :: OS Independent", 24 | "Programming Language :: Python", 25 | "Programming Language :: Python :: 3 :: Only", 26 | "Programming Language :: Python :: 3.10", 27 | "Programming Language :: Python :: 3.11", 28 | "Programming Language :: Python :: 3.12", 29 | "Programming Language :: Python :: 3.13", 30 | "Programming Language :: Python :: 3.14", 31 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content", 32 | "Topic :: Software Development", 33 | "Topic :: Software Development :: Libraries :: Application Frameworks", 34 | ] 35 | dynamic = [ 36 | "version", 37 | ] 38 | dependencies = [ 39 | "django>=4.2", 40 | "django-js-asset>=3.1.2", 41 | ] 42 | 43 | optional-dependencies.sanitize = [ 44 | "nh3>=0.3", 45 | ] 46 | optional-dependencies.tests = [ 47 | "asgiref", 48 | "coverage", 49 | "nh3>=0.3", 50 | "pytest", 51 | "pytest-asyncio", 52 | "pytest-cov", 53 | "pytest-django", 54 | "pytest-playwright", 55 | ] 56 | urls.Documentation = "https://django-prose-editor.readthedocs.io/" 57 | 58 | urls.Homepage = "https://github.com/matthiask/django-prose-editor/" 59 | 60 | [tool.hatch.build] 61 | include = [ 62 | "django_prose_editor/", 63 | ] 64 | 65 | [tool.hatch.version] 66 | path = "django_prose_editor/__init__.py" 67 | 68 | [tool.ruff] 69 | target-version = "py311" 70 | 71 | fix = true 72 | show-fixes = true 73 | lint.extend-select = [ 74 | # flake8-bugbear 75 | "B", 76 | # flake8-comprehensions 77 | "C4", 78 | # mmcabe 79 | "C90", 80 | # flake8-django 81 | "DJ", 82 | "E", 83 | # pyflakes, pycodestyle 84 | "F", 85 | # flake8-boolean-trap 86 | "FBT", 87 | # flake8-logging-format 88 | "G", 89 | # isort 90 | "I", 91 | # flake8-gettext 92 | "INT", 93 | # pep8-naming 94 | "N", 95 | # pygrep-hooks 96 | "PGH", 97 | # flake8-pie 98 | "PIE", 99 | # pylint 100 | "PLC", 101 | "PLE", 102 | "PLW", 103 | # flake8-pytest-style 104 | "PT", 105 | # unused noqa 106 | "RUF100", 107 | # pyupgrade 108 | "UP", 109 | "W", 110 | # flake8-2020 111 | "YTT", 112 | ] 113 | lint.extend-ignore = [ 114 | # Stop pestering me, the default raise behavior is great 115 | "B904", 116 | # Allow zip() without strict= 117 | "B905", 118 | # No line length errors 119 | "E501", 120 | ] 121 | lint.per-file-ignores."*/migrat*/*" = [ 122 | # Allow using PascalCase model names in migrations 123 | "N806", 124 | # Ignore the fact that migration files are invalid module names 125 | "N999", 126 | ] 127 | lint.isort.combine-as-imports = true 128 | lint.isort.lines-after-imports = 2 129 | lint.mccabe.max-complexity = 15 130 | 131 | [tool.pytest.ini_options] 132 | DJANGO_SETTINGS_MODULE = "testapp.settings" 133 | python_files = "test_*.py" 134 | addopts = "--strict-markers" 135 | testpaths = [ "tests" ] 136 | asyncio_mode = "strict" 137 | asyncio_default_fixture_loop_scope = "function" 138 | markers = [ 139 | "e2e: End-to-end browser tests", 140 | ] 141 | -------------------------------------------------------------------------------- /tests/testapp/test_prose_editor.py: -------------------------------------------------------------------------------- 1 | from django import test 2 | from django.contrib.auth.models import User 3 | from django.test import Client 4 | 5 | from django_prose_editor.widgets import prose_editor_admin_media, prose_editor_media 6 | from testapp.models import ( 7 | ProseEditorModel, 8 | SanitizedProseEditorModel, 9 | ) 10 | 11 | 12 | class Test(test.TestCase): 13 | def test_standard_field(self): 14 | m = ProseEditorModel(description="

") 15 | m.full_clean() 16 | assert m.description == "" 17 | 18 | m = ProseEditorModel(description="

") 19 | m.full_clean() 20 | assert m.description == "" 21 | 22 | m = ProseEditorModel(description="

hello

") 23 | m.full_clean() 24 | assert m.description == "

hello

" 25 | 26 | def test_sanitized_field(self): 27 | m = SanitizedProseEditorModel( 28 | description="

Hello

" 29 | ) 30 | m.full_clean() 31 | assert m.description == "

Hello

" 32 | 33 | m = SanitizedProseEditorModel(description="

") 34 | m.full_clean() 35 | assert m.description == "" 36 | 37 | m = SanitizedProseEditorModel(description="

") 38 | m.full_clean() 39 | assert m.description == "" 40 | 41 | m = SanitizedProseEditorModel(description="

hello

") 42 | m.full_clean() 43 | assert m.description == "

hello

" 44 | 45 | def test_admin(self): 46 | client = Client() 47 | client.force_login( 48 | User.objects.create_superuser("admin", "admin@example.com", "password") 49 | ) 50 | 51 | response = client.get("/admin/testapp/proseeditormodel/add/") 52 | # print(response, response.content.decode("utf-8")) 53 | self.assertContains( 54 | response, 'href="/static/django_prose_editor/overrides.css"' 55 | ) 56 | 57 | def test_utilities(self): 58 | assert ( 59 | str(prose_editor_media()) 60 | == """\ 61 | 62 | 63 | 64 | """ 65 | ) 66 | 67 | assert ( 68 | str(prose_editor_media(preset="configurable")) 69 | == """\ 70 | 71 | 72 | 73 | """ 74 | ) 75 | 76 | assert ( 77 | str(prose_editor_media(base=prose_editor_admin_media)) 78 | == """\ 79 | 80 | 81 | 82 | 83 | 84 | """ 85 | ) 86 | -------------------------------------------------------------------------------- /src/configurable.js: -------------------------------------------------------------------------------- 1 | import * as editorModule from "django-prose-editor/editor" 2 | import { 3 | createTextareaEditor, 4 | initializeEditors, 5 | } from "django-prose-editor/editor" 6 | 7 | const marker = "data-django-prose-editor-configurable" 8 | 9 | const EXTENSIONS = { ...editorModule } 10 | 11 | const moduleLoadPromises = new Map() 12 | 13 | async function loadExtensionModules(moduleUrls) { 14 | if (!moduleUrls || !moduleUrls.length) return 15 | 16 | const loadPromises = moduleUrls.map((url) => { 17 | if (moduleLoadPromises.has(url)) { 18 | return moduleLoadPromises.get(url) 19 | } 20 | 21 | const loadPromise = import(url) 22 | .then((module) => { 23 | Object.assign(EXTENSIONS, module) 24 | }) 25 | .catch((error) => { 26 | console.error(`Error loading extension module from ${url}:`, error) 27 | // Remove failed modules from cache 28 | moduleLoadPromises.delete(url) 29 | }) 30 | 31 | moduleLoadPromises.set(url, loadPromise) 32 | return loadPromise 33 | }) 34 | 35 | // Wait for all modules to load 36 | await Promise.all(loadPromises) 37 | } 38 | 39 | async function createEditorAsync(textarea, config = null) { 40 | if (textarea.closest(".prose-editor")) return null 41 | 42 | config = config || JSON.parse(textarea.getAttribute(marker) || "{}") 43 | 44 | if (config.js_modules?.length) { 45 | await loadExtensionModules(config.js_modules) 46 | } 47 | 48 | const extensions = [] 49 | 50 | // Process all extensions from the config 51 | for (const [extensionName, extensionConfig] of Object.entries( 52 | config.extensions, 53 | )) { 54 | const extension = EXTENSIONS[extensionName] 55 | if (extension) { 56 | // If the extension has a configuration object (not empty), pass it to the extension 57 | if (typeof extensionConfig === "object") { 58 | extensions.push(extension.configure(extensionConfig)) 59 | } else { 60 | extensions.push(extension) 61 | } 62 | } 63 | } 64 | 65 | return createTextareaEditor(textarea, extensions) 66 | } 67 | 68 | // Track pending editor initializations 69 | const pendingEditors = new WeakMap() 70 | 71 | // Function for the initializeEditors callback 72 | function createEditor(textarea, config = null) { 73 | // Check if we already have a pending initialization for this textarea 74 | if (pendingEditors.has(textarea)) { 75 | return pendingEditors.get(textarea) 76 | } 77 | 78 | // Create a promise for the editor initialization 79 | const editorPromise = createEditorAsync(textarea, config) 80 | .then((editor) => { 81 | // The editor is initialized and ready to use 82 | if (editor) { 83 | const event = new CustomEvent("prose-editor:ready", { 84 | detail: { editor, textarea }, 85 | bubbles: true, 86 | }) 87 | textarea.dispatchEvent(event) 88 | } 89 | // Remove from pending tracking once complete 90 | pendingEditors.delete(textarea) 91 | return editor 92 | }) 93 | .catch((error) => { 94 | console.error("Error initializing prose editor:", error) 95 | // Remove from pending tracking on error 96 | pendingEditors.delete(textarea) 97 | return null 98 | }) 99 | 100 | // Track this pending initialization 101 | pendingEditors.set(textarea, editorPromise) 102 | 103 | // Return the promise 104 | return editorPromise 105 | } 106 | 107 | // Initialize all editors with the configurable marker 108 | initializeEditors(createEditor, `[${marker}]`) 109 | 110 | // Export utility functions for external use 111 | export { createEditor } 112 | -------------------------------------------------------------------------------- /src/editor.js: -------------------------------------------------------------------------------- 1 | import "./editor.css" 2 | import "./dialog.css" 3 | import "./fullscreen.css" 4 | import "./menu.css" 5 | 6 | export * from "@tiptap/core" 7 | export { Blockquote } from "@tiptap/extension-blockquote" 8 | export { Bold } from "@tiptap/extension-bold" 9 | export { Code } from "@tiptap/extension-code" 10 | export { CodeBlock } from "@tiptap/extension-code-block" 11 | export { Color } from "@tiptap/extension-color" 12 | export { Document } from "@tiptap/extension-document" 13 | export { HardBreak } from "@tiptap/extension-hard-break" 14 | export { Heading } from "@tiptap/extension-heading" 15 | export { Highlight } from "@tiptap/extension-highlight" 16 | export { HorizontalRule } from "@tiptap/extension-horizontal-rule" 17 | export { Image } from "@tiptap/extension-image" 18 | export { Italic } from "@tiptap/extension-italic" 19 | export { BulletList, ListItem } from "@tiptap/extension-list" 20 | export { Paragraph } from "@tiptap/extension-paragraph" 21 | export { Strike } from "@tiptap/extension-strike" 22 | export { Subscript } from "@tiptap/extension-subscript" 23 | export { Superscript } from "@tiptap/extension-superscript" 24 | export { TableCell, TableHeader, TableRow } from "@tiptap/extension-table" 25 | export { Text } from "@tiptap/extension-text" 26 | export { TextAlign } from "@tiptap/extension-text-align" 27 | export { TextStyle } from "@tiptap/extension-text-style" 28 | export { Underline } from "@tiptap/extension-underline" 29 | export { 30 | Dropcursor, 31 | Gapcursor, 32 | Placeholder, 33 | TrailingNode, 34 | } from "@tiptap/extensions" 35 | export { Plugin } from "@tiptap/pm/state" 36 | export { Caption, Figure } from "./figure.js" 37 | export { Fullscreen } from "./fullscreen.js" 38 | export * from "./history.js" 39 | export { HTML } from "./html.js" 40 | export { Link } from "./link.js" 41 | export * from "./menu.js" 42 | export { NodeClass } from "./nodeClass.js" 43 | export { NoSpellCheck } from "./nospellcheck.js" 44 | export { OrderedList } from "./orderedList.js" 45 | export * as pm from "./pm.js" 46 | export { Table } from "./table.js" 47 | export { TextClass } from "./textClass.js" 48 | export { Typographic } from "./typographic.js" 49 | export * from "./utils.js" 50 | 51 | import { Editor } from "@tiptap/core" 52 | import { crel } from "./utils.js" 53 | 54 | function actuallyEmpty(html) { 55 | const re = /^<(\w+)(\s[^>]*)?><\/\1>$/i 56 | return re.test(html) ? "" : html 57 | } 58 | 59 | export function createTextareaEditor(textarea, extensions) { 60 | const disabled = textarea.hasAttribute("disabled") 61 | 62 | const element = crel("div", { 63 | className: `prose-editor ${disabled ? "disabled" : ""}`, 64 | }) 65 | textarea.before(element) 66 | element.append(textarea) 67 | 68 | const editor = new Editor({ 69 | element, 70 | editable: !disabled, 71 | extensions, 72 | content: textarea.value, 73 | onUpdate({ editor }) { 74 | textarea.value = actuallyEmpty(editor.getHTML()) 75 | textarea.dispatchEvent(new Event("input", { bubbles: true })) 76 | }, 77 | onDestroy() { 78 | element.before(textarea) 79 | element.remove() 80 | }, 81 | }) 82 | 83 | return editor 84 | } 85 | 86 | export function initializeEditors(create, selector) { 87 | function initializeEditor(container) { 88 | for (const el of container.querySelectorAll(selector)) { 89 | if (!el.id.includes("__prefix__")) { 90 | create(el) 91 | } 92 | } 93 | } 94 | 95 | function initializeInlines() { 96 | let o 97 | if ((o = window.django) && (o = o.jQuery)) { 98 | o(document).on("formset:added", (e) => { 99 | initializeEditor(e.target) 100 | }) 101 | } 102 | } 103 | 104 | initializeEditor(document) 105 | initializeInlines() 106 | } 107 | -------------------------------------------------------------------------------- /src/link.js: -------------------------------------------------------------------------------- 1 | import { Link as BaseLink } from "@tiptap/extension-link" 2 | 3 | import { gettext, updateAttrsDialog } from "./utils.js" 4 | 5 | const linkDialogImpl = (editor, attrs, options) => { 6 | const properties = { 7 | href: { 8 | type: "string", 9 | title: gettext("URL"), 10 | }, 11 | title: { 12 | type: "string", 13 | title: gettext("Title"), 14 | }, 15 | } 16 | 17 | if (options.enableTarget) 18 | properties.openInNewWindow = { 19 | type: "boolean", 20 | title: gettext("Open in new window"), 21 | } 22 | 23 | return updateAttrsDialog(properties, { 24 | title: gettext("Edit Link"), 25 | })(editor, attrs) 26 | } 27 | const linkDialog = async (editor, attrs, options) => { 28 | attrs = attrs || {} 29 | attrs.openInNewWindow = attrs.target === "_blank" 30 | attrs = await linkDialogImpl(editor, attrs, options) 31 | if (attrs) { 32 | if (attrs.openInNewWindow) { 33 | attrs.target = "_blank" 34 | attrs.rel = "noopener" 35 | } else { 36 | attrs.target = null 37 | attrs.rel = null 38 | } 39 | return attrs 40 | } 41 | } 42 | 43 | export const Link = BaseLink.extend({ 44 | addOptions() { 45 | return { 46 | ...this.parent?.(), 47 | openOnClick: false, 48 | enableTarget: true, 49 | HTMLAttributes: { 50 | target: null, 51 | rel: null, 52 | class: null, 53 | title: "", 54 | }, 55 | } 56 | }, 57 | 58 | addAttributes() { 59 | return { 60 | ...this.parent?.(), 61 | title: { 62 | default: this.options.HTMLAttributes.title, 63 | }, 64 | } 65 | }, 66 | 67 | addMenuItems({ menu, buttons }) { 68 | menu.defineItem({ 69 | name: "link", 70 | groups: "link", 71 | command(editor) { 72 | editor.chain().addLink().focus().run() 73 | }, 74 | enabled(editor) { 75 | return !editor.state.selection.empty || editor.isActive("link") 76 | }, 77 | button: buttons.material("insert_link", "insert link"), 78 | active(editor) { 79 | return editor.isActive("link") 80 | }, 81 | }) 82 | 83 | menu.defineItem({ 84 | name: "unlink", 85 | groups: "link", 86 | command(editor) { 87 | editor.chain().focus().unsetLink().run() 88 | }, 89 | dom: buttons.material("link_off", "remove link"), 90 | hidden(editor) { 91 | return !editor.isActive("link") 92 | }, 93 | }) 94 | }, 95 | 96 | addCommands() { 97 | return { 98 | ...this.parent?.(), 99 | addLink: 100 | () => 101 | ({ editor }) => { 102 | if (!editor.state.selection.empty || editor.isActive("link")) { 103 | const attrs = editor.getAttributes(this.name) 104 | 105 | linkDialog(editor, attrs, this.options).then((attrs) => { 106 | if (attrs) { 107 | if (editor.isActive("link")) { 108 | editor 109 | .chain() 110 | .focus() 111 | .extendMarkRange(this.name) 112 | .updateAttributes(this.name, attrs) 113 | .run() 114 | } else { 115 | editor.chain().focus().setMark(this.name, attrs).run() 116 | } 117 | } 118 | }) 119 | } 120 | }, 121 | } 122 | }, 123 | 124 | addKeyboardShortcuts() { 125 | return { 126 | "Mod-k": ({ editor }) => { 127 | let e 128 | if ((e = window.event)) { 129 | /* Disable browser behavior of focussing the search bar or whatever */ 130 | e.preventDefault() 131 | } 132 | editor.commands.addLink() 133 | }, 134 | } 135 | }, 136 | }) 137 | -------------------------------------------------------------------------------- /src/menu.css: -------------------------------------------------------------------------------- 1 | .prose-menubar:not(:empty) { 2 | font-size: 14px; 3 | display: inline-flex; 4 | align-items: stretch; 5 | gap: 8px; 6 | flex-wrap: wrap; 7 | background: var(--_b); 8 | padding: 4px; 9 | width: 100%; 10 | box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); 11 | border-bottom: 1px solid var(--_r); 12 | box-sizing: border-box; 13 | z-index: 10; 14 | position: sticky; 15 | top: 0; 16 | } 17 | 18 | .prose-editor.disabled .prose-menubar { 19 | display: none; 20 | } 21 | 22 | .prose-menubar__group { 23 | display: flex; 24 | } 25 | 26 | .prose-menubar__button { 27 | cursor: pointer; 28 | height: 28px; 29 | padding: 0 0.25em; 30 | min-width: 2em; 31 | transition-property: 32 | color, background, border-radius, filter, opacity, transform; 33 | transition-duration: 0.25s; 34 | background: var(--_b); 35 | color: var(--_f); 36 | border: 1px solid var(--_r); 37 | display: inline-flex; 38 | align-items: center; 39 | justify-content: center; 40 | position: relative; 41 | } 42 | 43 | .prose-menubar__button.hidden { 44 | display: none !important; 45 | } 46 | 47 | .prose-menubar__button--heading::after { 48 | content: attr(data-level); 49 | position: absolute; 50 | font-family: sans-serif; 51 | right: 4px; 52 | bottom: 5px; 53 | font-size: 12px; 54 | } 55 | 56 | .prose-menubar__button:not(.hidden) { 57 | border-top-left-radius: 4px; 58 | border-bottom-left-radius: 4px; 59 | } 60 | 61 | /* Cancel rounded borders on the left side for second to last button in group */ 62 | .prose-menubar__button:not(.hidden) ~ .prose-menubar__button:not(.hidden) { 63 | border-top-left-radius: 0; 64 | border-bottom-left-radius: 0; 65 | } 66 | 67 | /* Find last button in group (the button which doesn't have a following button) */ 68 | .prose-menubar__button:not(.hidden):not( 69 | :has(~ .prose-menubar__button:not(.hidden)) 70 | ) { 71 | border-top-right-radius: 4px; 72 | border-bottom-right-radius: 4px; 73 | } 74 | 75 | .prose-menubar__button + .prose-menubar__button { 76 | border-left: none; 77 | } 78 | 79 | .prose-menubar__button.material-icons { 80 | padding: 0 0.125em; 81 | min-width: auto; 82 | } 83 | 84 | .prose-menubar__button:hover { 85 | filter: brightness(110%); 86 | } 87 | 88 | .prose-menubar__button.active { 89 | background-color: var(--_a); 90 | } 91 | 92 | .prose-menubar__button.disabled:not(.active) { 93 | background: var(--_d); 94 | filter: brightness(100%); 95 | cursor: not-allowed; 96 | opacity: 0.3; 97 | } 98 | 99 | /* SVG button styling */ 100 | .prose-menubar__button svg { 101 | display: inline-block; 102 | vertical-align: middle; 103 | width: 20px; 104 | height: 20px; 105 | } 106 | 107 | .prose-menubar__button svg * { 108 | color: inherit; 109 | } 110 | 111 | .prose-menubar__dropdown { 112 | display: block; 113 | position: relative; 114 | } 115 | 116 | .prose-menubar__selected { 117 | cursor: pointer; 118 | display: block; 119 | height: 28px; 120 | outline: 1px solid var(--_r); 121 | border-radius: 4px; 122 | padding-right: 1rem !important; 123 | position: relative; 124 | 125 | &::after { 126 | content: "⏷"; 127 | position: absolute; 128 | right: 4px; 129 | top: 6px; 130 | } 131 | 132 | :first-child { 133 | border: none; 134 | } 135 | 136 | > * { 137 | pointer-events: none; 138 | } 139 | } 140 | 141 | .prose-menubar__picker:popover-open { 142 | all: initial; 143 | position: absolute; 144 | } 145 | 146 | .prose-menubar__picker .ProseMirror { 147 | display: flex !important; 148 | flex-direction: column !important; 149 | padding: 0 !important; 150 | } 151 | 152 | .prose-menubar__picker .ProseMirror > * { 153 | cursor: pointer; 154 | padding: 4px 8px !important; 155 | margin: 0 !important; 156 | line-height: 1.2 !important; 157 | flex: 0 0 2rem !important; 158 | display: flex !important; 159 | align-items: center !important; 160 | 161 | &:hover { 162 | background: var(--_a) !important; 163 | } 164 | 165 | &.hidden { 166 | display: none !important; 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /django_prose_editor/static/django_prose_editor/overrides.css: -------------------------------------------------------------------------------- 1 | .ProseMirror{color:var(--body-fg);background:var(--body-bg);padding:1rem 1.75rem;font-family:-apple-system,blinkmacsystemfont,Segoe UI,roboto,oxygen,ubuntu,cantarell,Open Sans,Helvetica Neue,sans-serif;outline:none!important;margin:0!important}.ProseMirror *{color:inherit;background:inherit}.ProseMirror p{font-size:15px!important}.ProseMirror h1{margin:.67em 0!important;font-size:2em!important;font-weight:700!important;line-height:1.2!important;display:block!important}.ProseMirror h2{padding:0;margin:.83em 0!important;font-size:1.5em!important;font-weight:700!important;line-height:1.2!important;display:block!important}.ProseMirror h3{margin:1em 0!important;padding:0!important;font-size:1.17em!important;font-weight:700!important;line-height:1.2!important;display:block!important}.ProseMirror>*+*{margin-top:.75em!important}.ProseMirror p.is-editor-empty:first-child:before{content:attr(data-placeholder);float:left;color:#a8a8a8;pointer-events:none;height:0}.ProseMirror ul{list-style-type:disc!important}.ProseMirror ol:not([type]){list-style-type:decimal!important}.ProseMirror ol[type=a],.ProseMirror ol[data-type=lower-alpha]{list-style-type:lower-alpha!important}.ProseMirror ol[data-type=upper-alpha]{list-style-type:upper-alpha!important}.ProseMirror ol[type=i],.ProseMirror ol[data-type=lower-roman]{list-style-type:lower-roman!important}.ProseMirror ol[data-type=upper-roman]{list-style-type:upper-roman!important}.ProseMirror li{margin:0;padding:0;list-style:inherit!important}.ProseMirror ol,.ProseMirror ul{margin:.3em 0!important;padding-left:2.5em!important}.ProseMirror ol li,.ProseMirror ul li{margin:.1em 0!important;padding-left:0!important;display:list-item!important;position:relative!important}.ProseMirror ol li::marker{color:var(--body-fg)!important;font-size:15px!important}.ProseMirror ul li::marker{color:var(--body-fg)!important;font-size:15px!important}.ProseMirror ol ol,.ProseMirror ul ul,.ProseMirror ol ul,.ProseMirror ul ol{margin:.1em 0 .1em .5em!important}.ProseMirror h1,.ProseMirror h2,.ProseMirror h3,.ProseMirror h4,.ProseMirror h5,.ProseMirror h6{text-transform:none;color:var(--body-fg);padding:0;line-height:1.1;background:0 0!important;border:none!important}.ProseMirror pre{color:#fff!important;background:#0d0d0d!important;border-radius:.5rem!important;padding:.75rem 1rem!important;font-family:JetBrainsMono,monospace!important}.ProseMirror pre code{color:#fff!important;background:0 0!important;padding:0!important;font-size:.8rem!important}.ProseMirror img{max-width:100%;height:auto}.ProseMirror blockquote{border-left:2px solid rgba(13,13,13,.1);padding-left:1rem}.ProseMirror hr{border:none;border-top:2px solid rgba(13,13,13,.1);margin:2rem 0!important}.ProseMirror a{text-decoration:underline}.ProseMirror table{border-collapse:collapse;table-layout:fixed;width:100%;margin:0;overflow:hidden}.ProseMirror table td,.ProseMirror table th,.ProseMirror table[show_borders=false]:hover td,.ProseMirror table[show_borders=false]:hover th{border:2px solid var(--border-color,#ced4da);vertical-align:top;box-sizing:border-box;min-width:1em;padding:3px 5px;position:relative}.ProseMirror table[show_borders=false] td,.ProseMirror table[show_borders=false] th{box-sizing:border-box;border:none}.ProseMirror table td>*,.ProseMirror table th>*{margin-bottom:0}.ProseMirror table th{text-align:left;font-weight:700}.ProseMirror table th p{font-weight:inherit}.ProseMirror table .selectedCell:after{z-index:2;content:"";pointer-events:none;background:rgba(200,200,255,.4);position:absolute;top:0;bottom:0;left:0;right:0}.ProseMirror table .column-resize-handle{pointer-events:none;background-color:#adf;width:4px;position:absolute;top:0;bottom:-2px;right:-2px}.ProseMirror label{max-width:auto;width:auto;min-width:0;display:inline}.tableWrapper{overflow-x:auto}.resize-cursor{cursor:col-resize}.ProseMirror [draggable][contenteditable=false]{-webkit-user-select:text;-moz-user-select:text;user-select:text}.ProseMirror-selectednode{outline:2px solid var(--_a,#8cf)}li.ProseMirror-selectednode{outline:none}li.ProseMirror-selectednode:after{content:"";pointer-events:none;border:2px solid #8cf;position:absolute;top:-2px;bottom:-2px;left:-32px;right:-2px} 2 | /*# sourceMappingURL=overrides.css.map*/ -------------------------------------------------------------------------------- /django_prose_editor/widgets.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django import forms 4 | from django.conf import settings 5 | from django.core.serializers.json import DjangoJSONEncoder 6 | from js_asset import JS, importmap, static_lazy 7 | 8 | from django_prose_editor.config import ( 9 | expand_extensions, 10 | js_from_extensions, 11 | ) 12 | 13 | 14 | importmap.update( 15 | { 16 | "imports": { 17 | "django-prose-editor/editor": static_lazy("django_prose_editor/editor.js"), 18 | "django-prose-editor/configurable": static_lazy( 19 | "django_prose_editor/configurable.js" 20 | ), 21 | } 22 | } 23 | ) 24 | 25 | #: These three module-level variables are somewhat part of the API. 26 | prose_editor_js = JS("django_prose_editor/editor.js", {"type": "module"}) 27 | prose_editor_base_media = forms.Media( 28 | css={ 29 | "all": [ 30 | "django_prose_editor/material-icons.css", 31 | "django_prose_editor/editor.css", 32 | ] 33 | }, 34 | js=[ 35 | # We don't really need this since editor.js will be loaded 36 | # in default.js (or other presets' modules) anyway, but keeping 37 | # the tag around helps the browser discover and load this 38 | # module a little bit earlier. 39 | prose_editor_js, 40 | ], 41 | ) 42 | prose_editor_admin_media = ( 43 | forms.Media( 44 | js=[importmap, prose_editor_js] 45 | ) # Sneak the importmap into the admin 46 | + prose_editor_base_media 47 | + forms.Media( 48 | css={ 49 | "all": [ 50 | "django_prose_editor/editor.css", # For the ordering 51 | "django_prose_editor/overrides.css", 52 | ] 53 | } 54 | ) 55 | ) 56 | 57 | 58 | def prose_editor_presets(): 59 | return getattr(settings, "DJANGO_PROSE_EDITOR_PRESETS", {}) | { 60 | "default": [ 61 | prose_editor_js, 62 | JS("django_prose_editor/default.js", {"type": "module"}), 63 | ], 64 | "configurable": [ 65 | prose_editor_js, 66 | JS("django_prose_editor/configurable.js", {"type": "module"}), 67 | ], 68 | } 69 | 70 | 71 | def prose_editor_media(*, base=prose_editor_base_media, preset="default"): 72 | """ 73 | Utility for returning a ``forms.Media`` instance containing everything you 74 | need to initialize a prose editor in the frontend (hopefully!) 75 | """ 76 | return base + forms.Media(js=[prose_editor_js, *prose_editor_presets()[preset]]) 77 | 78 | 79 | class ProseEditorWidget(forms.Textarea): 80 | def __init__(self, *args, **kwargs): 81 | self.config = kwargs.pop("config", {}) 82 | self.preset = kwargs.pop("preset", "default") 83 | super().__init__(*args, **kwargs) 84 | 85 | @property 86 | def media(self): 87 | return prose_editor_media(preset=self.preset) 88 | 89 | def get_config(self): 90 | config = self.config or { 91 | "types": None, 92 | "history": True, 93 | "html": True, 94 | "typographic": True, 95 | } 96 | 97 | # New-style config with "extensions" key 98 | if isinstance(config, dict) and "extensions" in config: 99 | config = config | { 100 | "extensions": expand_extensions(config["extensions"]), 101 | "js_modules": js_from_extensions(config["extensions"]), 102 | } 103 | 104 | return config 105 | 106 | def get_context(self, name, value, attrs): 107 | context = super().get_context(name, value, attrs) 108 | context["widget"]["attrs"][f"data-django-prose-editor-{self.preset}"] = ( 109 | json.dumps(self.get_config(), separators=(",", ":"), cls=DjangoJSONEncoder) 110 | ) 111 | return context 112 | 113 | def use_required_attribute(self, _initial): 114 | # See github.com/feincms/django-prose-editor/issues/66 115 | return False 116 | 117 | 118 | class AdminProseEditorWidget(ProseEditorWidget): 119 | @property 120 | def media(self): 121 | return prose_editor_media( 122 | base=prose_editor_admin_media, 123 | preset=self.preset, 124 | ) 125 | -------------------------------------------------------------------------------- /django_prose_editor/static/django_prose_editor/default.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"default.js","sources":["../../../src/default.js"],"sourcesContent":["import {\n Blockquote,\n Bold,\n BulletList,\n createTextareaEditor,\n Document,\n Dropcursor,\n Gapcursor,\n HardBreak,\n Heading,\n History,\n HorizontalRule,\n HTML,\n Italic,\n initializeEditors,\n Link,\n ListItem,\n Menu,\n NoSpellCheck,\n OrderedList,\n Paragraph,\n Strike,\n Subscript,\n Superscript,\n Table,\n TableCell,\n TableHeader,\n TableRow,\n Text,\n Typographic,\n Underline,\n} from \"django-prose-editor/editor\"\n\nconst marker = \"data-django-prose-editor-default\"\n\nfunction createEditor(textarea, config = null) {\n if (textarea.closest(\".prose-editor\")) return\n\n if (!config) {\n config = JSON.parse(textarea.getAttribute(marker))\n }\n\n // Default extension types (table explicitly excluded)\n const DEFAULT_TYPES = [\n \"Blockquote\",\n \"Bold\",\n \"BulletList\",\n \"Heading\",\n \"HorizontalRule\",\n \"Italic\",\n \"Link\",\n \"OrderedList\",\n \"Strike\",\n \"Subscript\",\n \"Superscript\",\n \"Underline\",\n ]\n\n const createIsTypeEnabled =\n (enabledTypes) =>\n (...types) => {\n // If no types defined, use the defaults\n const typesToCheck = enabledTypes?.length ? enabledTypes : DEFAULT_TYPES\n return !!types.find((t) => typesToCheck.includes(t))\n }\n const isTypeEnabled = createIsTypeEnabled(config.types)\n\n const extensions = [\n Document,\n Dropcursor,\n Gapcursor,\n Paragraph,\n HardBreak,\n Text,\n config.history && History,\n Menu,\n config.html && HTML,\n NoSpellCheck,\n config.typographic && Typographic,\n // Nodes and marks\n isTypeEnabled(\"Blockquote\") && Blockquote,\n isTypeEnabled(\"Bold\", \"strong\") && Bold,\n isTypeEnabled(\"BulletList\", \"bullet_list\") && BulletList,\n isTypeEnabled(\"Heading\") &&\n Heading.configure({ levels: config.headingLevels || [1, 2, 3, 4, 5] }),\n isTypeEnabled(\"HorizontalRule\", \"horizontal_rule\") && HorizontalRule,\n isTypeEnabled(\"Italic\", \"em\") && Italic,\n isTypeEnabled(\"Link\", \"link\") && Link,\n isTypeEnabled(\"BulletList\", \"bullet_list\", \"OrderedList\", \"ordered_list\") &&\n ListItem,\n isTypeEnabled(\"OrderedList\", \"ordered_list\") && OrderedList,\n isTypeEnabled(\"Strike\", \"strikethrough\") && Strike,\n isTypeEnabled(\"Subscript\", \"sub\") && Subscript,\n isTypeEnabled(\"Superscript\", \"sup\") && Superscript,\n isTypeEnabled(\"Underline\") && Underline,\n // Table support\n isTypeEnabled(\"Table\") && Table,\n isTypeEnabled(\"Table\") && TableRow,\n isTypeEnabled(\"Table\") && TableHeader,\n isTypeEnabled(\"Table\") && TableCell,\n ].filter(Boolean)\n\n const editor = createTextareaEditor(textarea, extensions)\n const event = new CustomEvent(\"prose-editor:ready\", {\n detail: { editor, textarea },\n bubbles: true,\n })\n textarea.dispatchEvent(event)\n return editor\n}\n\ninitializeEditors((textarea) => {\n return createEditor(textarea)\n}, `[${marker}]`)\n\n// Backwards compatibility shim for django-prose-editor < 0.10\nwindow.DjangoProseEditor = { createEditor }\n"],"names":["marker","createEditor","textarea","config","enabledTypes","JSON","DEFAULT_TYPES","isTypeEnabled","types","typesToCheck","t","editor","createTextareaEditor","Document","Dropcursor","Gapcursor","Paragraph","HardBreak","Text","History","Menu","HTML","NoSpellCheck","Typographic","Blockquote","Bold","BulletList","Heading","HorizontalRule","Italic","Link","ListItem","OrderedList","Strike","Subscript","Superscript","Underline","Table","TableRow","TableHeader","TableCell","Boolean","event","CustomEvent","initializeEditors","window"],"mappings":"seAiCA,IAAMA,EAAS,mCAEf,SAASC,EAAaC,CAAQ,CAAEC,EAAS,IAAI,MAwBxCC,EAvBH,GAAIF,EAAS,OAAO,CAAC,iBAAkB,MAEnC,CAACC,GACHA,CAAAA,EAASE,KAAK,KAAK,CAACH,EAAS,YAAY,CAACF,GAAO,EAInD,IAAMM,EAAgB,CACpB,aACA,OACA,aACA,UACA,iBACA,SACA,OACA,cACA,SACA,YACA,cACA,YACD,CASKC,GANHH,EAMuCD,EAAO,KAAK,CALpD,CAAC,GAAGK,KAEF,IAAMC,EAAeL,AAAAA,CAAAA,MAAAA,EAAAA,KAAAA,EAAAA,EAAc,MAAM,AAAD,EAAIA,EAAeE,EAC3D,MAAO,CAAC,CAACE,EAAM,IAAI,CAAC,AAACE,GAAMD,EAAa,QAAQ,CAACC,GACnD,GAsCIC,EAASC,EAAqBV,EAnCjB,CACjBW,EACAC,EACAC,EACAC,EACAC,EACAC,EACAf,EAAO,OAAO,EAAIgB,EAClBC,EACAjB,EAAO,IAAI,EAAIkB,EACfC,EACAnB,EAAO,WAAW,EAAIoB,EAEtBhB,EAAc,eAAiBiB,EAC/BjB,EAAc,OAAQ,WAAakB,EACnClB,EAAc,aAAc,gBAAkBmB,EAC9CnB,EAAc,YACZoB,EAAQ,SAAS,CAAC,CAAE,OAAQxB,EAAO,aAAa,EAAI,CAAC,EAAG,EAAG,EAAG,EAAG,EAAE,AAAC,GACtEI,EAAc,iBAAkB,oBAAsBqB,EACtDrB,EAAc,SAAU,OAASsB,EACjCtB,EAAc,OAAQ,SAAWuB,EACjCvB,EAAc,aAAc,cAAe,cAAe,iBACxDwB,EACFxB,EAAc,cAAe,iBAAmByB,EAChDzB,EAAc,SAAU,kBAAoB0B,EAC5C1B,EAAc,YAAa,QAAU2B,EACrC3B,EAAc,cAAe,QAAU4B,EACvC5B,EAAc,cAAgB6B,EAE9B7B,EAAc,UAAY8B,EAC1B9B,EAAc,UAAY+B,EAC1B/B,EAAc,UAAYgC,EAC1BhC,EAAc,UAAYiC,EAC3B,CAAC,MAAM,CAACC,UAGHC,EAAQ,IAAIC,YAAY,qBAAsB,CAClD,OAAQ,CAAEhC,OAAAA,EAAQT,SAAAA,CAAS,EAC3B,QAAS,EACX,GAEA,OADAA,EAAS,aAAa,CAACwC,GAChB/B,CACT,CAEAiC,EAAkB,AAAC1C,GACVD,EAAaC,GACnB,CAAC,CAAC,EAAEF,EAAO,CAAC,CAAC,EAGhB6C,OAAO,iBAAiB,CAAG,CAAE5C,aAAAA,CAAa"} -------------------------------------------------------------------------------- /src/html.js: -------------------------------------------------------------------------------- 1 | import { Extension } from "@tiptap/core" 2 | 3 | import { gettext, updateAttrsDialog } from "./utils.js" 4 | 5 | const htmlDialog = updateAttrsDialog( 6 | { 7 | html: { 8 | type: "string", 9 | title: "HTML", 10 | description: gettext( 11 | "The HTML contents of the editor. Note that the allowed HTML is restricted by the editor schema.", 12 | ), 13 | format: "textarea", 14 | }, 15 | }, 16 | { 17 | title: gettext("Edit HTML"), 18 | actions: [ 19 | { 20 | text: gettext("Prettify"), 21 | handler: (_currentValues, form) => { 22 | const htmlTextarea = form.querySelector("textarea") 23 | if (htmlTextarea) { 24 | const prettifiedHTML = prettifyHTML(htmlTextarea.value) 25 | htmlTextarea.value = prettifiedHTML 26 | htmlTextarea.dispatchEvent(new Event("input")) 27 | } 28 | }, 29 | }, 30 | ], 31 | }, 32 | ) 33 | 34 | const areArraysEqual = (arr1, arr2) => 35 | Array.isArray(arr1) && 36 | Array.isArray(arr2) && 37 | arr1.length === arr2.length && 38 | arr1.every((val, index) => Object.is(val, arr2[index])) 39 | 40 | // Simple HTML prettifier that adds newlines and basic indentation 41 | const prettifyHTML = (html) => { 42 | if (!html) return html 43 | 44 | // Extract
 tags and their content to preserve whitespace
 45 |   const preBlocks = []
 46 |   const preRegex = /]*)?>[\s\S]*?<\/pre>/gi
 47 |   let formatted = html.replace(preRegex, (match) => {
 48 |     const placeholder = `__PRE_BLOCK_${preBlocks.length}__`
 49 |     preBlocks.push(match)
 50 |     return placeholder
 51 |   })
 52 | 
 53 |   // List of block elements that should have newlines
 54 |   const blockElements = [
 55 |     "div",
 56 |     "p",
 57 |     "h1",
 58 |     "h2",
 59 |     "h3",
 60 |     "h4",
 61 |     "h5",
 62 |     "h6",
 63 |     "ul",
 64 |     "ol",
 65 |     "li",
 66 |     "table",
 67 |     "tr",
 68 |     "td",
 69 |     "th",
 70 |     "thead",
 71 |     "tbody",
 72 |     "tfoot",
 73 |     "section",
 74 |     "article",
 75 |     "header",
 76 |     "footer",
 77 |     "aside",
 78 |     "nav",
 79 |     "blockquote",
 80 |     "figure",
 81 |     "figcaption",
 82 |     "form",
 83 |     "fieldset",
 84 |   ]
 85 | 
 86 |   // Create regex patterns for opening and closing tags (only need to compile once)
 87 |   const closingRE = new RegExp(``, "gi")
 88 |   const openingRE = new RegExp(
 89 |     `<(${blockElements.join("|")})(?:\\s+[^>]*)?>`,
 90 |     "gi",
 91 |   )
 92 | 
 93 |   // Add newlines before opening and after closing block tags
 94 |   formatted = formatted.replace(closingRE, "\n").replace(openingRE, "\n$&")
 95 | 
 96 |   // Split into lines and filter out empty lines
 97 |   const lines = formatted.split("\n").filter((line) => line.trim())
 98 | 
 99 |   let indentLevel = 0
100 | 
101 |   // Process each line for indentation
102 |   for (let i = 0; i < lines.length; i++) {
103 |     const line = lines[i].trim()
104 | 
105 |     // Skip indentation for lines containing pre block placeholders
106 |     if (line.includes("__PRE_BLOCK_")) {
107 |       continue
108 |     }
109 | 
110 |     const closing = [...line.matchAll(closingRE)]
111 |     const opening = [...line.matchAll(openingRE)]
112 | 
113 |     // Check if this line has matching opening and closing tags (same element)
114 |     // If so, we don't change indentation for this element
115 |     const hasSelfContainedElement =
116 |       closing.length &&
117 |       opening.length &&
118 |       areArraysEqual(closing[0].slice(1), opening[0].slice(1))
119 | 
120 |     // Check for closing tags on this line and adjust indent (unless self-contained)
121 |     if (!hasSelfContainedElement && closing.length) {
122 |       indentLevel = Math.max(0, indentLevel - closing.length)
123 |     }
124 | 
125 |     // Apply indentation
126 |     lines[i] = " ".repeat(indentLevel * 2) + line
127 | 
128 |     // Check for opening tags on this line and adjust indent for next line (unless self-contained)
129 |     if (!hasSelfContainedElement && opening.length) {
130 |       indentLevel += opening.length
131 |     }
132 |   }
133 | 
134 |   // Restore 
 blocks with their original whitespace
135 |   let result = lines.join("\n")
136 |   preBlocks.forEach((preBlock, index) => {
137 |     result = result.replace(`__PRE_BLOCK_${index}__`, preBlock)
138 |   })
139 | 
140 |   return result
141 | }
142 | 
143 | export const HTML = Extension.create({
144 |   name: "html",
145 | 
146 |   addCommands() {
147 |     return {
148 |       editHTML:
149 |         () =>
150 |         ({ editor }) => {
151 |           // Show current HTML without automatic prettification
152 |           const currentHTML = editor.getHTML()
153 | 
154 |           htmlDialog(editor, { html: currentHTML }).then((attrs) => {
155 |             if (attrs) {
156 |               editor.chain().focus().setContent(attrs.html, true).run()
157 |             }
158 |           })
159 |         },
160 |     }
161 |   },
162 | })
163 | 


--------------------------------------------------------------------------------
/django_prose_editor/static/django_prose_editor/configurable.js.map:
--------------------------------------------------------------------------------
1 | {"version":3,"file":"configurable.js","sources":["../../../src/configurable.js"],"sourcesContent":["import * as editorModule from \"django-prose-editor/editor\"\nimport {\n  createTextareaEditor,\n  initializeEditors,\n} from \"django-prose-editor/editor\"\n\nconst marker = \"data-django-prose-editor-configurable\"\n\nconst EXTENSIONS = { ...editorModule }\n\nconst moduleLoadPromises = new Map()\n\nasync function loadExtensionModules(moduleUrls) {\n  if (!moduleUrls || !moduleUrls.length) return\n\n  const loadPromises = moduleUrls.map((url) => {\n    if (moduleLoadPromises.has(url)) {\n      return moduleLoadPromises.get(url)\n    }\n\n    const loadPromise = import(url)\n      .then((module) => {\n        Object.assign(EXTENSIONS, module)\n      })\n      .catch((error) => {\n        console.error(`Error loading extension module from ${url}:`, error)\n        // Remove failed modules from cache\n        moduleLoadPromises.delete(url)\n      })\n\n    moduleLoadPromises.set(url, loadPromise)\n    return loadPromise\n  })\n\n  // Wait for all modules to load\n  await Promise.all(loadPromises)\n}\n\nasync function createEditorAsync(textarea, config = null) {\n  if (textarea.closest(\".prose-editor\")) return null\n\n  config = config || JSON.parse(textarea.getAttribute(marker) || \"{}\")\n\n  if (config.js_modules?.length) {\n    await loadExtensionModules(config.js_modules)\n  }\n\n  const extensions = []\n\n  // Process all extensions from the config\n  for (const [extensionName, extensionConfig] of Object.entries(\n    config.extensions,\n  )) {\n    const extension = EXTENSIONS[extensionName]\n    if (extension) {\n      // If the extension has a configuration object (not empty), pass it to the extension\n      if (typeof extensionConfig === \"object\") {\n        extensions.push(extension.configure(extensionConfig))\n      } else {\n        extensions.push(extension)\n      }\n    }\n  }\n\n  return createTextareaEditor(textarea, extensions)\n}\n\n// Track pending editor initializations\nconst pendingEditors = new WeakMap()\n\n// Function for the initializeEditors callback\nfunction createEditor(textarea, config = null) {\n  // Check if we already have a pending initialization for this textarea\n  if (pendingEditors.has(textarea)) {\n    return pendingEditors.get(textarea)\n  }\n\n  // Create a promise for the editor initialization\n  const editorPromise = createEditorAsync(textarea, config)\n    .then((editor) => {\n      // The editor is initialized and ready to use\n      if (editor) {\n        const event = new CustomEvent(\"prose-editor:ready\", {\n          detail: { editor, textarea },\n          bubbles: true,\n        })\n        textarea.dispatchEvent(event)\n      }\n      // Remove from pending tracking once complete\n      pendingEditors.delete(textarea)\n      return editor\n    })\n    .catch((error) => {\n      console.error(\"Error initializing prose editor:\", error)\n      // Remove from pending tracking on error\n      pendingEditors.delete(textarea)\n      return null\n    })\n\n  // Track this pending initialization\n  pendingEditors.set(textarea, editorPromise)\n\n  // Return the promise\n  return editorPromise\n}\n\n// Initialize all editors with the configurable marker\ninitializeEditors(createEditor, `[${marker}]`)\n\n// Export utility functions for external use\nexport { createEditor }\n"],"names":["marker","EXTENSIONS","editorModule","moduleLoadPromises","Map","pendingEditors","WeakMap","createEditor","textarea","config","editorPromise","createEditorAsync","_config_js_modules","moduleUrls","JSON","loadPromises","url","loadPromise","module","Object","error","console","Promise","extensions","extensionName","extensionConfig","extension","createTextareaEditor","editor","event","CustomEvent","initializeEditors"],"mappings":"kWAMA,IAAMA,EAAS,wCAETC,EAAa,A,iaAAA,GAAKC,GAElBC,EAAqB,IAAIC,IA0DzBC,EAAiB,IAAIC,QAG3B,SAASC,EAAaC,CAAQ,CAAEC,EAAS,IAAI,EAE3C,GAAIJ,EAAe,GAAG,CAACG,GACrB,OAAOH,EAAe,GAAG,CAACG,GAI5B,IAAME,EAAgBC,AAxCxB,UAAiCH,CAAQ,CAAEC,EAAS,IAAI,E,yBAKlDG,EA/B8BC,EA2BlC,GAAIL,EAAS,OAAO,CAAC,iBAAkB,OAAO,IAI1C,QAAAI,CAAAA,EAAAA,AAFJH,CAAAA,EAASA,GAAUK,KAAK,KAAK,CAACN,EAAS,YAAY,CAACR,IAAW,KAAI,EAExD,UAAU,AAAD,EAAhBY,KAAAA,EAAAA,EAAmB,MAAM,AAAD,GAC1B,OAhCgCC,EAgCLJ,EAAO,UAAU,C,cA/B9C,GAAI,CAACI,GAAc,CAACA,EAAW,MAAM,CAAE,OAEvC,IAAME,EAAeF,EAAW,GAAG,CAAC,AAACG,IACnC,GAAIb,EAAmB,GAAG,CAACa,GACzB,OAAOb,EAAmB,GAAG,CAACa,GAGhC,IAAMC,EAAc,MAAM,CAACD,GACxB,IAAI,CAAC,AAACE,IACLC,OAAO,MAAM,CAAClB,EAAYiB,EAC5B,GACC,KAAK,CAAC,AAACE,IACNC,QAAQ,KAAK,CAAC,CAAC,oCAAoC,EAAEL,EAAI,CAAC,CAAC,CAAEI,GAE7DjB,EAAmB,MAAM,CAACa,EAC5B,GAGF,OADAb,EAAmB,GAAG,CAACa,EAAKC,GACrBA,CACT,EAGA,OAAMK,QAAQ,GAAG,CAACP,EACpB,KAQgD,EAG9C,IAAMQ,EAAa,EAAE,CAGrB,IAAK,GAAM,CAACC,EAAeC,EAAgB,GAAIN,OAAO,OAAO,CAC3DV,EAAO,UAAU,EAChB,CACD,IAAMiB,EAAYzB,CAAU,CAACuB,EAAc,CACvCE,IAEE,AAA2B,UAA3B,OAAOD,EACTF,EAAW,IAAI,CAACG,EAAU,SAAS,CAACD,IAEpCF,EAAW,IAAI,CAACG,GAGtB,CAEA,MAAOC,AAAAA,GAAAA,EAAAA,oBAAAA,AAAAA,EAAqBnB,EAAUe,EACxC,I,GAa0Cf,EAAUC,GAC/C,IAAI,CAAC,AAACmB,IAEL,GAAIA,EAAQ,CACV,IAAMC,EAAQ,IAAIC,YAAY,qBAAsB,CAClD,OAAQ,CAAEF,OAAAA,EAAQpB,SAAAA,CAAS,EAC3B,QAAS,EACX,GACAA,EAAS,aAAa,CAACqB,EACzB,CAGA,OADAxB,EAAe,MAAM,CAACG,GACfoB,CACT,GACC,KAAK,CAAC,AAACR,IACNC,QAAQ,KAAK,CAAC,mCAAoCD,GAElDf,EAAe,MAAM,CAACG,GACf,OAOX,OAHAH,EAAe,GAAG,CAACG,EAAUE,GAGtBA,CACT,CAGAqB,AAAAA,GAAAA,EAAAA,iBAAAA,AAAAA,EAAkBxB,EAAc,CAAC,CAAC,EAAEP,EAAO,CAAC,CAAC,S"}


--------------------------------------------------------------------------------
/docs/sanitization.rst:
--------------------------------------------------------------------------------
  1 | Sanitization and Security
  2 | =========================
  3 | 
  4 | Server-side Sanitization
  5 | ------------------------
  6 | 
  7 | The recommended approach for sanitization is to use the extensions mechanism with the ``sanitize=True`` parameter. This automatically generates appropriate sanitization rules for nh3 based on your specific extension configuration:
  8 | 
  9 | .. code-block:: python
 10 | 
 11 |     # Enable sanitization based on extension configuration
 12 |     content = ProseEditorField(
 13 |         extensions={"Bold": True, "Link": True},
 14 |         sanitize=True
 15 |     )
 16 | 
 17 | This ensures that the sanitization ruleset precisely matches your enabled extensions, providing strict security with minimal impact on legitimate content.
 18 | 
 19 | How Sanitization Works with Extensions
 20 | --------------------------------------
 21 | 
 22 | When you enable extensions, the sanitization system automatically generates rules that match your configuration. For example:
 23 | 
 24 | .. code-block:: python
 25 | 
 26 |     content = ProseEditorField(
 27 |         extensions={
 28 |             "Link": {
 29 |                 "protocols": ["http", "https", "mailto"],  # Only allow these protocols
 30 |             }
 31 |         },
 32 |         sanitize=True
 33 |     )
 34 | 
 35 | This will automatically restrict URLs during sanitization to only the specified protocols, removing any links with other protocols like ``javascript:`` or ``data:``.
 36 | 
 37 | Accessing Sanitization Rules Directly
 38 | -------------------------------------
 39 | 
 40 | You can also access the generated sanitization rules directly:
 41 | 
 42 | .. code-block:: python
 43 | 
 44 |     from django_prose_editor.config import allowlist_from_extensions
 45 | 
 46 |     allowlist = allowlist_from_extensions(extensions={"Bold": True, "Link": True})
 47 |     # Returns {"tags": ["strong", "a"], "attributes": {"a": ["href", "title", "rel", "target"]}}
 48 | 
 49 | Creating Custom Sanitizers
 50 | ---------------------------
 51 | 
 52 | You can create a custom sanitizer function from any extension configuration using the `create_sanitizer` utility:
 53 | 
 54 | .. code-block:: python
 55 | 
 56 |     from django_prose_editor.fields import create_sanitizer
 57 | 
 58 |     # Create a sanitizer function for a specific set of extensions
 59 |     my_sanitizer = create_sanitizer({
 60 |         "Bold": True,
 61 |         "Italic": True,
 62 |         "Link": {"enableTarget": True}
 63 |     })
 64 | 
 65 |     # Use the sanitizer in your code
 66 |     sanitized_html = my_sanitizer(unsafe_html)
 67 | 
 68 | This is particularly useful when you need a standalone sanitizer that matches your editor configuration without using the entire field.
 69 | 
 70 | Extension-to-HTML Mapping
 71 | -------------------------
 72 | 
 73 | This table shows how editor extensions map to HTML elements and attributes:
 74 | 
 75 | ============== ======================= ============================
 76 | Extension      HTML Elements           HTML Attributes
 77 | ============== ======================= ============================
 78 | Bold                           -
 79 | Italic                             -
 80 | Strike                              -
 81 | Underline                           -
 82 | Subscript                         -
 83 | Superscript                       -
 84 | Heading        

to

- 85 | BulletList