hello
") 23 | m.full_clean() 24 | assert m.description == "hello
" 25 | 26 | def test_sanitized_field(self): 27 | m = SanitizedProseEditorModel( 28 | 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(`(${blockElements.join("|")})>`, "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, "$1>\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 -
86 | OrderedList start, type
87 | ListItem - -
88 | Blockquote
-
89 | HorizontalRule
-
90 | Link href, title, target, rel
91 | Table , , rowspan, colspan
92 | ,
93 | ============== ======================= ============================
94 |
95 | Custom Sanitization
96 | -------------------
97 |
98 | You can also pass your own callable receiving and returning HTML
99 | using the ``sanitize`` keyword argument if you need custom sanitization logic:
100 |
101 | .. code-block:: python
102 |
103 | def my_custom_sanitizer(html):
104 | # Your custom sanitization logic here
105 | return cleaned_html
106 |
107 | content = ProseEditorField(
108 | extensions={"Bold": True, "Link": True},
109 | sanitize=my_custom_sanitizer
110 | )
111 |
112 | Note that when using a custom sanitizer, you're responsible for ensuring that the sanitization rules match your enabled extensions.
113 |
114 | Security Best Practices
115 | -----------------------
116 |
117 | 1. **Always use sanitization**: Enable ``sanitize=True`` or provide a custom sanitizer
118 | 2. **Match extensions to sanitization**: Use the extension-based sanitization to ensure consistency between what the editor allows and what gets sanitized
119 | 3. **Restrict protocols**: When using Link extensions, limit protocols to trusted schemes (http, https, mailto)
120 | 4. **Validate on the server**: Never trust client-side validation alone - always sanitize on the server side
121 | 5. **Regular updates**: Keep nh3 and django-prose-editor updated for security patches
122 | 6. **Test your configuration**: Verify that your sanitization rules work as expected with your specific extension configuration
123 |
--------------------------------------------------------------------------------
/tests/testapp/test_form_field.py:
--------------------------------------------------------------------------------
1 | from django import forms
2 | from django.contrib.admin import widgets
3 | from django.test import TestCase
4 |
5 | from django_prose_editor.fields import ProseEditorFormField, _is
6 | from django_prose_editor.widgets import AdminProseEditorWidget, ProseEditorWidget
7 |
8 |
9 | class CustomWidget(forms.Textarea):
10 | """A custom widget that is not a ProseEditorWidget."""
11 |
12 |
13 | class IsHelperFunctionTest(TestCase):
14 | def test_is_function(self):
15 | """Test the _is helper function for widget type checking."""
16 | # Test with classes
17 | assert _is(ProseEditorWidget, ProseEditorWidget)
18 | assert _is(CustomWidget, forms.Textarea)
19 | assert not _is(CustomWidget, ProseEditorWidget)
20 |
21 | # Test with instances
22 | assert _is(ProseEditorWidget(), ProseEditorWidget)
23 | assert _is(CustomWidget(), forms.Textarea)
24 | assert not _is(ProseEditorWidget(), widgets.AdminTextareaWidget)
25 |
26 |
27 | class ProseEditorFormFieldTest(TestCase):
28 | def test_widget_handling(self):
29 | """Test the widget handling in ProseEditorFormField."""
30 | # Test when widget is None (default case, line 80 branch)
31 | field = ProseEditorFormField()
32 | assert isinstance(field.widget, ProseEditorWidget)
33 |
34 | # Test when widget is a class that is not a ProseEditorWidget (line 81 branch)
35 | field = ProseEditorFormField(widget=CustomWidget)
36 | assert isinstance(field.widget, ProseEditorWidget)
37 |
38 | # Test when widget is an instance that is not a ProseEditorWidget (line 81 branch)
39 | field = ProseEditorFormField(widget=CustomWidget())
40 | assert isinstance(field.widget, ProseEditorWidget)
41 |
42 | # Test with AdminTextareaWidget class (line 79 branch)
43 | field = ProseEditorFormField(widget=widgets.AdminTextareaWidget)
44 | assert isinstance(field.widget, AdminProseEditorWidget)
45 |
46 | # Test with AdminTextareaWidget instance (line 79 branch)
47 | field = ProseEditorFormField(widget=widgets.AdminTextareaWidget())
48 | assert isinstance(field.widget, AdminProseEditorWidget)
49 |
50 | # For completeness, also test with a ProseEditorWidget class and instance
51 | field = ProseEditorFormField(widget=ProseEditorWidget)
52 | assert isinstance(field.widget, ProseEditorWidget)
53 |
54 | field = ProseEditorFormField(widget=ProseEditorWidget())
55 | assert isinstance(field.widget, ProseEditorWidget)
56 |
57 | def test_cleaning(self):
58 | class Form(forms.Form):
59 | content = ProseEditorFormField(sanitize=lambda html: "Hello")
60 |
61 | form = Form({"content": "World"})
62 | assert form.is_valid()
63 | assert form.cleaned_data == {"content": "Hello"}
64 |
65 | def test_form_field_extensions(self):
66 | class Form(forms.Form):
67 | content = ProseEditorFormField(
68 | config={"extensions": {"Bold": True}}, sanitize=True
69 | )
70 |
71 | form = Form({"content": "Hello World"})
72 | assert form.is_valid()
73 | assert form.cleaned_data == {"content": "Hello World"}
74 |
75 | print(form.fields["content"].config)
76 | print(form.fields["content"].preset)
77 |
78 | assert "Bold" in form.fields["content"].config["extensions"]
79 | assert form.fields["content"].preset == "configurable"
80 |
81 |
82 | class FormWithProseEditorField(forms.Form):
83 | """A form using ProseEditorFormField with different widget configurations."""
84 |
85 | # Default widget (None)
86 | content_default = ProseEditorFormField()
87 |
88 | # Non-ProseEditorWidget class
89 | content_custom_class = ProseEditorFormField(widget=CustomWidget)
90 |
91 | # Non-ProseEditorWidget instance
92 | content_custom_instance = ProseEditorFormField(widget=CustomWidget())
93 |
94 | # AdminTextareaWidget class
95 | content_admin_class = ProseEditorFormField(widget=widgets.AdminTextareaWidget)
96 |
97 | # AdminTextareaWidget instance
98 | content_admin_instance = ProseEditorFormField(widget=widgets.AdminTextareaWidget())
99 |
100 |
101 | class FormRenderingTest(TestCase):
102 | def test_form_rendering(self):
103 | """Test that forms with different widget configurations render correctly."""
104 | form = FormWithProseEditorField()
105 |
106 | # The form should render all fields with appropriate ProseEditor widgets
107 | html = form.as_p()
108 |
109 | # Count the number of data-django-prose-editor attributes
110 | # All fields should use the data attribute with default preset
111 | assert html.count("data-django-prose-editor-default") == 5
112 |
113 | # All fields should use ProseEditor widgets (either standard or admin)
114 | total_prose_editors = len(
115 | [
116 | field
117 | for field in form.fields.values()
118 | if isinstance(field.widget, ProseEditorWidget | AdminProseEditorWidget)
119 | ]
120 | )
121 | assert total_prose_editors == 5 # All 5 fields should use ProseEditor widgets
122 |
--------------------------------------------------------------------------------
/AGENTS.md:
--------------------------------------------------------------------------------
1 | # Agent Notes for django-prose-editor
2 |
3 | This document contains information for AI agents working on this project.
4 |
5 | ## Project Structure
6 |
7 | - **Python/Django Backend**: Django app in `django_prose_editor/`
8 | - **JavaScript/TypeScript Frontend**: Tiptap editor extensions in `src/`
9 | - **Tests**: Python tests in `tests/`, uses Playwright for E2E tests
10 | - **Documentation**: ReStructuredText files in `docs/`
11 |
12 | ## Running Tests
13 |
14 | Use `tox` to run tests:
15 |
16 | ```bash
17 | # Run tests with Python 3.13 and Django 5.2
18 | tox -e py313-dj52
19 | ```
20 |
21 | Tests include both unit tests and Playwright E2E tests. The test suite will automatically install Chromium if needed.
22 |
23 | ## JavaScript Development
24 |
25 | The project uses:
26 | - **Tiptap** for the rich text editor
27 | - **rslib** for building JavaScript modules
28 | - **prek** for linting and formatting (Rust-based pre-commit alternative, runs Biome and other tools)
29 |
30 | JavaScript source files are in `src/` and get built into `django_prose_editor/static/`.
31 |
32 | **IMPORTANT**: After modifying JavaScript files in `src/`, you MUST rebuild with:
33 | ```bash
34 | yarn prod
35 | ```
36 |
37 | The tests run against the compiled JavaScript in `django_prose_editor/static/`, not the source files.
38 |
39 | ## Documentation
40 |
41 | Documentation is written in ReStructuredText (`.rst`) format in the `docs/` directory.
42 |
43 | When modifying extensions or features:
44 | 1. Update the relevant `.rst` file in `docs/`
45 | 2. Include code examples in both Python and JavaScript
46 | 3. Document configuration options, usage patterns, and HTML output
47 |
48 | ## Code Organization
49 |
50 | ### Tiptap Extensions
51 |
52 | - Extensions are in `src/`
53 | - Each extension typically exports a Tiptap extension object
54 | - Extensions can add attributes to nodes/marks using `addGlobalAttributes()`
55 | - Menu items are added via `addMenuItems({ buttons, menu })`
56 |
57 | ### Key Patterns
58 |
59 | **Distinguishing Nodes vs Marks:**
60 | ```javascript
61 | const isNodeType = (editor, typeName) => {
62 | return !!editor.state.schema.nodes[typeName]
63 | }
64 | ```
65 |
66 | **Getting Applicable Items:**
67 | - For nodes: Walk up the document tree from the selection
68 | - For marks: Check marks at the current selection position
69 |
70 | **Menu Items:**
71 | - Use `active()` to determine if option should be highlighted
72 | - Use `hidden()` to determine if option should be shown
73 | - For marks: Hide when selection is empty or mark type not active
74 | - For nodes: Hide when node type is not in ancestor chain
75 |
76 | ## Testing Workflow
77 |
78 | 1. Make code changes
79 | 2. **If you modified JavaScript**: Run `yarn prod` to rebuild
80 | 3. Run linting/formatting: `prek run --all-files` (or let it run on commit)
81 | 4. Run tests: `tox -e py313-dj52`
82 | 5. Verify all tests pass (35 tests expected as of 2025-11-04)
83 | 6. Update documentation if needed
84 |
85 | ## Common Tasks
86 |
87 | ### Adding Support for Both Nodes and Marks
88 |
89 | When an extension needs to support both nodes and marks:
90 |
91 | 1. Use a single configuration object (e.g., `cssClasses`)
92 | 2. Check the schema at runtime to determine if type is node or mark
93 | 3. Handle nodes with `setNodeAttribute()` and transactions
94 | 4. Handle marks with `setMark()` and `isActive()`
95 | 5. Update menu `hidden()` logic appropriately for each type
96 |
97 | ### Configuration Options
98 |
99 | Extensions are configured in two ways:
100 | - **Python**: Via `ProseEditorField(extensions={...})`
101 | - **JavaScript**: Via `Extension.configure({...})`
102 |
103 | Keep both configuration methods documented and in sync.
104 |
105 | ## ProseMirror/Tiptap Patterns
106 |
107 | ### Modifying Mark Attributes
108 |
109 | **Important**: Use Tiptap's `updateAttributes()` command to modify mark attributes. This preserves all other attributes automatically:
110 |
111 | ```javascript
112 | // ✅ CORRECT - Updates only the specified attribute, preserves others
113 | editor.chain()
114 | .extendMarkRange(typeName)
115 | .updateAttributes(typeName, { class: 'newValue' })
116 | .run()
117 |
118 | // To remove an attribute, set it to null
119 | editor.chain()
120 | .extendMarkRange(typeName)
121 | .updateAttributes(typeName, { class: null })
122 | .run()
123 | ```
124 |
125 | This automatically preserves other attributes like `href` on links, `src` on images, etc. without needing to manually spread existing attributes.
126 |
127 | ### Extending Mark Range
128 |
129 | When working with marks at a collapsed selection (cursor position), use `extendMarkRange()` to select the entire mark:
130 |
131 | ```javascript
132 | editor.chain()
133 | .extendMarkRange('bold') // Selects entire bold region
134 | .setMark('bold', { class: 'emphasis' })
135 | .run()
136 | ```
137 |
138 | ### Checking Active Marks
139 |
140 | To check if a mark exists at the cursor position, use the resolved position's marks, not just `isActive()`:
141 |
142 | ```javascript
143 | const { state } = editor
144 | const { $from } = state.selection
145 | const markType = state.schema.marks[typeName]
146 | const marks = $from.marks()
147 | const hasMark = marks.some(mark => mark.type === markType)
148 | ```
149 |
150 | The `isActive()` method can be unreliable at mark boundaries or with collapsed selections.
151 |
--------------------------------------------------------------------------------
/src/orderedList.js:
--------------------------------------------------------------------------------
1 | import { OrderedList as TiptapOrderedList } from "@tiptap/extension-list"
2 |
3 | import { gettext, updateAttrsDialog } from "./utils.js"
4 |
5 | // Define all list types in a single source of truth
6 | const LIST_TYPES = [
7 | {
8 | label: "1, 2, 3, ...",
9 | htmlType: "1",
10 | cssType: "decimal",
11 | description: gettext("Decimal numbers"),
12 | },
13 | {
14 | label: "a, b, c, ...",
15 | htmlType: "a",
16 | cssType: "lower-alpha",
17 | description: gettext("Lowercase letters"),
18 | },
19 | {
20 | label: "A, B, C, ...",
21 | htmlType: "A",
22 | cssType: "upper-alpha",
23 | description: gettext("Uppercase letters"),
24 | },
25 | {
26 | label: "i, ii, iii, ...",
27 | htmlType: "i",
28 | cssType: "lower-roman",
29 | description: gettext("Lowercase Roman numerals"),
30 | },
31 | {
32 | label: "I, II, III, ...",
33 | htmlType: "I",
34 | cssType: "upper-roman",
35 | description: gettext("Uppercase Roman numerals"),
36 | },
37 | ]
38 |
39 | // Helper to convert list type label to HTML type attribute
40 | const listTypeToHTMLType = (typeLabel) => {
41 | const found = LIST_TYPES.find((item) => item.label === typeLabel)
42 | return found ? found.htmlType : "1" // Default to decimal
43 | }
44 |
45 | const htmlTypeToCSSType = (type) => {
46 | const found = LIST_TYPES.find((item) => item.htmlType === type)
47 | return found ? found.cssType : "decimal" // Default to decimal
48 | }
49 |
50 | // Helper to convert HTML type attribute to list type label
51 | const htmlTypeToListType = (htmlType) => {
52 | const found = LIST_TYPES.find((item) => item.htmlType === htmlType)
53 | return found ? found.label : LIST_TYPES[0].label // Default to first option
54 | }
55 |
56 | export const listPropertiesDialog = updateAttrsDialog(
57 | {
58 | start: {
59 | type: "number",
60 | title: gettext("Start at"),
61 | format: "number",
62 | default: "1",
63 | min: "1",
64 | },
65 | listType: {
66 | title: gettext("List type"),
67 | enum: LIST_TYPES.map((item) => item.label),
68 | default: "",
69 | },
70 | },
71 | {
72 | title: gettext("List properties"),
73 | submitText: gettext("Update"),
74 | },
75 | )
76 |
77 | /**
78 | * Custom OrderedList extension that overrides the default input rules
79 | * to prevent automatic list creation when typing "1. " at the beginning of a line.
80 | */
81 | export const OrderedList = TiptapOrderedList.configure({
82 | // Set keepMarks and keepAttributes to default values
83 | keepMarks: false,
84 | keepAttributes: false,
85 | // Default HTML attributes
86 | HTMLAttributes: {},
87 | }).extend({
88 | addInputRules() {
89 | // Return an empty array to disable the default input rule (1. → ordered list)
90 | return []
91 | },
92 |
93 | addOptions() {
94 | return {
95 | ...this.parent?.(),
96 | // Option to enable/disable list attributes dialog and menu
97 | enableListAttributes: true,
98 | }
99 | },
100 |
101 | addAttributes() {
102 | return {
103 | ...this.parent?.(),
104 | type: {
105 | default: null,
106 | parseHTML: (element) => element.getAttribute("type"),
107 | renderHTML: (attributes) => ({
108 | type: attributes.type,
109 | "data-type": htmlTypeToCSSType(attributes.type),
110 | }),
111 | },
112 | }
113 | },
114 |
115 | addCommands() {
116 | return {
117 | ...this.parent?.(),
118 | updateListAttributes:
119 | () =>
120 | ({ editor }) => {
121 | // Check if list attributes dialog is enabled
122 | if (!this.options.enableListAttributes) {
123 | return false
124 | }
125 |
126 | // Get the ordered list node
127 | const { state } = editor
128 | const { selection } = state
129 | // Try different depths to find the list node
130 | let listNode
131 | for (let depth = 1; depth <= 3; depth++) {
132 | try {
133 | const node = selection.$anchor.node(-depth)
134 | if (node && node.type.name === "orderedList") {
135 | listNode = node
136 | break
137 | }
138 | } catch (_e) {
139 | // Node at this depth doesn't exist
140 | }
141 | }
142 |
143 | if (!listNode) {
144 | // Fallback to defaults if we can't find the node
145 | listNode = { attrs: { start: 1, type: "1" } }
146 | }
147 |
148 | // Extract current attributes
149 | const start = listNode?.attrs?.start || 1
150 | const type = listNode?.attrs?.type || "1"
151 |
152 | listPropertiesDialog(editor, {
153 | start: String(start),
154 | listType: htmlTypeToListType(type),
155 | }).then((attrs) => {
156 | if (attrs) {
157 | // Convert settings to attributes
158 | const listType = listTypeToHTMLType(attrs.listType)
159 | const startValue = Number.parseInt(attrs.start, 10) || 1
160 |
161 | // Apply attributes to ordered list
162 | editor
163 | .chain()
164 | .focus()
165 | .updateAttributes("orderedList", {
166 | start: startValue,
167 | type: listType,
168 | })
169 | .run()
170 | }
171 | })
172 | },
173 | }
174 | },
175 | })
176 |
--------------------------------------------------------------------------------
/docs/menu.rst:
--------------------------------------------------------------------------------
1 | Menu configuration
2 | ==================
3 |
4 | Menu items are defined as follows:
5 |
6 | .. code-block:: javascript
7 |
8 | menu.defineItem({
9 | name: "name",
10 | groups: "group1 group2 group3",
11 | // Higher priorities are sorted first
12 | priority: 100,
13 | // Apply the command:
14 | command(editor) {},
15 | // Is the item enabled?
16 | enabled(editor) { return true },
17 | // Should the item be shown as active, e.g. because the selection is inside
18 | // a node or mark of this particular type?
19 | active(editor) { return false },
20 | // Should this item be dynamically hidden?
21 | hidden(editor) { return false },
22 | // Run arbitrary updates
23 | update(editor) {}
24 |
25 | // Button: Element when shown in toolbar
26 | button: HTMLElement,
27 | // Option: Element when shown in dropdown
28 | option: HTMLElement | null,
29 | })
30 |
31 | Most of these are optional; the keys without which a menu item definition
32 | doesn't make much sense are ``name``, ``groups``, ``command`` and ``button``.
33 |
34 | Creating Custom Menus
35 | ----------------------
36 |
37 | Menu items can be organized and displayed using button groups and dropdowns:
38 |
39 | .. code-block:: javascript
40 |
41 | // Return a button group
42 | const group = menu.buttonGroup({ editor, buttons }, menu.items("blockType"))
43 |
44 | // Return a dropdown
45 | const dropdown = menu.dropdown({ editor, buttons }, menu.items("formats"))
46 |
47 | // Create a group from all node menu items except those also contained in
48 | // blockType:
49 | const group = menu.buttonGroup({ editor, buttons }, menu.items("nodes -blockType"))
50 |
51 | Defining button groups and dropdowns
52 | ------------------------------------
53 |
54 | The Menu extension accepts a ``groups`` option which allows defining the menu
55 | structure. The default is:
56 |
57 | .. code-block:: javascript
58 |
59 | const defaultGroups = [
60 | { group: "blockType -lists", type: "dropdown", minItems: 2 },
61 | { group: "lists" },
62 | { group: "nodes -blockType -lists" },
63 | { group: "marks" },
64 | { group: "nodeClass", type: "dropdown" },
65 | { group: "textClass", type: "dropdown" },
66 | { group: "link" },
67 | { group: "textAlign" },
68 | { group: "table" },
69 | { group: "history" },
70 | { group: "utility" },
71 | ]
72 |
73 | The same default value is available at
74 | ``django_prose_editor.config.DEFAULT_MENU_GROUPS``.
75 |
76 | Menu Creator Functions
77 | ----------------------
78 |
79 | If you need more control, you can use the menu creator function. The Menu
80 | extension accepts an ``items`` option which should be a function that creates
81 | the complete menu structure. This function receives ``{ editor, buttons, menu
82 | }`` and should return an array of DOM elements:
83 |
84 | .. code-block:: javascript
85 |
86 | // Create a custom menu layout
87 | function createMenu({ editor, buttons, menu }) {
88 | return [
89 | // Dropdown for block types (headings, lists, etc.)
90 | menu.dropdown({ editor, buttons }, menu.items("blockType -lists")),
91 | // Button group for lists
92 | menu.buttonGroup({ editor, buttons }, menu.items("lists")),
93 | // Button group for other nodes
94 | menu.buttonGroup({ editor, buttons }, menu.items("nodes -blockType -lists")),
95 | // Button group for text formatting
96 | menu.buttonGroup({ editor, buttons }, menu.items("marks")),
97 | // Dropdown for text classes
98 | menu.dropdown({ editor, buttons }, menu.items("textClass")),
99 | // Button group for links
100 | menu.buttonGroup({ editor, buttons }, menu.items("link")),
101 | // Button group for text alignment
102 | menu.buttonGroup({ editor, buttons }, menu.items("textAlign")),
103 | // Button group for tables
104 | menu.buttonGroup({ editor, buttons }, menu.items("table")),
105 | // Button group for history
106 | menu.buttonGroup({ editor, buttons }, menu.items("history")),
107 | // Button group for utilities
108 | menu.buttonGroup({ editor, buttons }, menu.items("utility")),
109 | ]
110 | }
111 |
112 | // Configure the menu to use your custom layout
113 | Menu.configure({
114 | items: createMenu,
115 | })
116 |
117 | Using the Groups Helper
118 | -----------------------
119 |
120 | For convenience, there's a ``createMenuFromGroups`` helper that converts a simple groups configuration into a menu creator function:
121 |
122 | .. code-block:: javascript
123 |
124 | import { createMenuFromGroups } from "django-prose-editor/menu"
125 |
126 | // Define your menu structure using groups
127 | const menuCreator = createMenuFromGroups([
128 | { group: "blockType -lists", type: "dropdown", minItems: 2 },
129 | { group: "lists" },
130 | { group: "nodes -blockType -lists" },
131 | { group: "marks" },
132 | { group: "textClass", type: "dropdown" },
133 | { group: "link" },
134 | { group: "textAlign" },
135 | { group: "table" },
136 | { group: "history" },
137 | { group: "utility" },
138 | ])
139 |
140 | // Use it with the Menu extension
141 | Menu.configure({
142 | items: menuCreator,
143 | })
144 |
145 | This helper creates button groups by default, but you can specify ``type:
146 | "dropdown"`` to create dropdowns instead. Also, ``minItems: 2`` in this example
147 | only adds the block type dropdown if the dropdown would have at least two items.
148 |
--------------------------------------------------------------------------------
/docs/configuration.rst:
--------------------------------------------------------------------------------
1 | Configuration
2 | =============
3 |
4 | Introduction
5 | ------------
6 |
7 | ProseMirror does a really good job of only allowing content which conforms to a
8 | particular scheme. Of course users can submit what they want, they are not
9 | constrainted by the HTML widgets you're using. You should always sanitize the
10 | HTML submitted on the server side.
11 |
12 | The recommended approach is to use the extensions mechanism for configuring the
13 | prose editor field which automatically synchronizes editor extensions with
14 | sanitization rules:
15 |
16 | .. code-block:: python
17 |
18 | from django_prose_editor.fields import ProseEditorField
19 |
20 | content = ProseEditorField(
21 | extensions={
22 | "Bold": True,
23 | "Italic": True,
24 | "BulletList": True,
25 | "ListItem": True,
26 | "Link": True,
27 | },
28 | sanitize=True, # Server side sanitization is strongly recommended.
29 | )
30 |
31 | This ensures that the HTML sanitization rules exactly match what the editor
32 | allows, preventing inconsistencies between editing capabilities and allowed
33 | output. Note that you need the nh3 library for this which is automatically
34 | installed when you specify the requirement as
35 | ``django-prose-editor[sanitize]``.
36 |
37 |
38 | Overview
39 | --------
40 |
41 | The editor can be customized in several ways:
42 |
43 | 1. Using the new extensions mechanism with ``ProseEditorField`` (recommended).
44 | 2. Using the ``config`` parameter to include/exclude specific extensions
45 | (legacy approach)
46 | 3. Creating custom presets for more advanced customization
47 |
48 | Note that the ``ProseEditorField`` automatically uses the extension mechanism
49 | when passing ``extensions`` and falls back to the legacy behavior otherwise.
50 |
51 |
52 | Example Configuration
53 | ---------------------
54 |
55 | The ``extensions`` parameter allows you to specify exactly which extensions you
56 | want to enable in your editor:
57 |
58 | .. code-block:: python
59 |
60 | from django_prose_editor.fields import ProseEditorField
61 |
62 | class Article(models.Model):
63 | content = ProseEditorField(
64 | extensions={
65 | # Core text formatting
66 | "Bold": True,
67 | "Italic": True,
68 | "Strike": True,
69 | "Underline": True,
70 | "HardBreak": True,
71 |
72 | # Structure
73 | "Heading": {
74 | "levels": [1, 2, 3] # Only allow h1, h2, h3
75 | },
76 | "BulletList": True,
77 | "OrderedList": True,
78 | "ListItem": True, # Used by BulletList and OrderedList
79 | "Blockquote": True,
80 |
81 | # Advanced extensions
82 | "Link": {
83 | "enableTarget": True, # Enable "open in new window"
84 | "protocols": ["http", "https", "mailto"], # Limit protocols
85 | },
86 | "Table": True,
87 | "TableRow": True,
88 | "TableHeader": True,
89 | "TableCell": True,
90 |
91 | # Editor capabilities
92 | "History": True, # Enables undo/redo
93 | "HTML": True, # Allows HTML view
94 | "Typographic": True, # Enables typographic chars
95 | }
96 | )
97 |
98 | You can also pass additional configurations to extensions:
99 |
100 | .. code-block:: python
101 |
102 | content = ProseEditorField(
103 | extensions={
104 | "Bold": True,
105 | "Italic": True,
106 | "Heading": {"levels": [1, 2, 3]}, # Only allow H1-H3
107 | "Link": {"enableTarget": False}, # Disable "open in new tab"
108 | }
109 | )
110 |
111 | Available extensions include:
112 |
113 | * Text formatting: ``Bold``, ``Italic``, ``Strike``, ``Subscript``, ``Superscript``, ``Underline``
114 | * Lists: ``BulletList``, ``OrderedList``, ``ListItem``
115 | * Structure: ``Blockquote``, ``Heading``, ``HorizontalRule``
116 | * Links: ``Link``
117 | * Tables: ``Table``, ``TableRow``, ``TableHeader``, ``TableCell``
118 |
119 | Check the source code for more!
120 |
121 | The extensions which are enabled by default are ``Document``, ``Paragraph`` and
122 | ``Text`` for the document, ``Menu``, ``History``, ``Dropcursor`` and
123 | ``Gapcursor`` for the editor functionality and ``NoSpellCheck`` to avoid ugly
124 | spell checker interference. You may disable some of these core extensions e.g.
125 | by adding ``"History": False`` to the extensions dict.
126 |
127 |
128 | Common Extension Configurations
129 | --------------------------------
130 |
131 | Django Prose Editor provides special configuration options for common extensions:
132 |
133 | **Heading Level Restrictions**
134 |
135 | You can restrict heading levels to a subset of H1-H6:
136 |
137 | .. code-block:: python
138 |
139 | content = ProseEditorField(
140 | extensions={
141 | "Heading": {
142 | "levels": [1, 2, 3], # Only allow H1, H2, H3
143 | }
144 | }
145 | )
146 |
147 | This configuration will only allow the specified heading levels in both the editor
148 | and the sanitized output.
149 |
150 | **Links without 'open in new tab' functionality**
151 |
152 | .. code-block:: python
153 |
154 | content = ProseEditorField(
155 | extensions={
156 | "Link": {
157 | "enableTarget": False,
158 | }
159 | }
160 | )
161 |
162 | The default is to show a checkbox for this function.
163 |
164 | **Link Protocol Restrictions**
165 |
166 | You can restrict which URL protocols are allowed:
167 |
168 | .. code-block:: python
169 |
170 | content = ProseEditorField(
171 | extensions={
172 | "Link": {
173 | "protocols": ["http", "https", "mailto"], # Only allow these protocols
174 | }
175 | }
176 | )
177 |
--------------------------------------------------------------------------------
/docs/system_checks.rst:
--------------------------------------------------------------------------------
1 | System Checks
2 | =============
3 |
4 | Django Prose Editor includes several system checks that help ensure your configuration is secure and follows best practices. These checks run automatically when you run ``python manage.py check`` and during the normal Django startup process.
5 |
6 | Error Checks
7 | ------------
8 |
9 | The following checks will raise an ``Error`` which should be addressed before deploying your application:
10 |
11 | .. list-table::
12 | :widths: 15 85
13 | :header-rows: 1
14 |
15 | * - Check ID
16 | - Description
17 | * - ``django_prose_editor.E001``
18 | - **Overriding the "default" preset in DJANGO_PROSE_EDITOR_PRESETS is not allowed.**
19 |
20 | This preset is used internally by the package and overriding it could break functionality.
21 |
22 | **Solution:** Remove the 'default' key from your DJANGO_PROSE_EDITOR_PRESETS setting.
23 |
24 | * - ``django_prose_editor.E002``
25 | - **Overriding the "configurable" preset in DJANGO_PROSE_EDITOR_PRESETS is not allowed.**
26 |
27 | This preset is used internally by the package and overriding it could break functionality.
28 |
29 | **Solution:** Remove the 'configurable' key from your DJANGO_PROSE_EDITOR_PRESETS setting.
30 |
31 | * - ``django_prose_editor.E003``
32 | - **DJANGO_PROSE_EDITOR_EXTENSIONS must be a list of dictionaries.**
33 |
34 | The custom extensions setting has an invalid format.
35 |
36 | **Solution:** Configure DJANGO_PROSE_EDITOR_EXTENSIONS as a list of dictionaries, each with 'js' and 'extensions' keys.
37 |
38 | * - ``django_prose_editor.E004``
39 | - **Extension group at index {i} must be a dictionary.**
40 |
41 | Each item in the DJANGO_PROSE_EDITOR_EXTENSIONS list must be a dictionary.
42 |
43 | **Solution:** Make sure each extension group is a dictionary with 'js' and 'extensions' keys.
44 |
45 | * - ``django_prose_editor.E005``
46 | - **Extension group at index {i} is missing the required 'extensions' key.**
47 |
48 | The 'extensions' key is required for each extension group.
49 |
50 | **Solution:** Add an 'extensions' key mapping extension names to processors.
51 |
52 | * - ``django_prose_editor.E006``
53 | - **The 'extensions' key in extension group at index {i} must be a dictionary.**
54 |
55 | The 'extensions' value must be a dictionary mapping extension names to processors.
56 |
57 | **Solution:** Make sure the 'extensions' key contains a dictionary mapping extension names to processor callables or dotted paths.
58 |
59 | * - ``django_prose_editor.E007``
60 | - **Processor for extension "{extension_name}" in group {i} must be a callable or a dotted path string.**
61 |
62 | Extension processors must be either callables or dotted import paths to callable functions.
63 |
64 | **Solution:** Provide either a callable or a dotted import path for the processor.
65 |
66 | * - ``django_prose_editor.E008``
67 | - **The 'js' key in extension group at index {i} must be a list.**
68 |
69 | The 'js' key should contain a list of JavaScript assets.
70 |
71 | **Solution:** Make sure the 'js' key is a list of JavaScript asset URLs.
72 |
73 | Warning Checks
74 | --------------
75 |
76 | The following checks will raise a ``Warning`` which indicates potential issues that should be addressed:
77 |
78 | .. list-table::
79 | :widths: 15 85
80 | :header-rows: 1
81 |
82 | * - Check ID
83 | - Description
84 | * - ``django_prose_editor.W001``
85 | - **This ProseEditorField is using the legacy configuration format which is deprecated.**
86 |
87 | The 'config' parameter without the 'extensions' key is deprecated and will be removed in a future version.
88 |
89 | **Solution:** Add the 'extensions' parameter explicitly to use the new configuration format. For example:
90 |
91 | .. code-block:: python
92 |
93 | content = ProseEditorField(
94 | config={"extensions": {"Bold": True, "Italic": True}},
95 | sanitize=True
96 | )
97 |
98 | * - ``django_prose_editor.W002``
99 | - **Extension group at index {i} is missing the 'js' key.**
100 |
101 | Each extension group should have a 'js' key listing JavaScript assets.
102 |
103 | **Solution:** Add a 'js' key with a list of JavaScript assets for the extensions.
104 |
105 | * - ``django_prose_editor.W003``
106 | - **Processor path "{processor}" for extension "{extension_name}" in group {i} may not be a valid dotted import path.**
107 |
108 | The processor string doesn't look like a valid Python dotted import path.
109 |
110 | **Solution:** The processor should be a dotted import path like 'myapp.processors.my_processor'.
111 |
112 | * - ``django_prose_editor.W004``
113 | - **This ProseEditorField is using extensions without sanitization.**
114 |
115 | When using extensions, it's recommended to enable sanitization for security.
116 |
117 | **Solution:** Add ``sanitize=True`` to your field definition:
118 |
119 | .. code-block:: python
120 |
121 | content = ProseEditorField(
122 | config={"extensions": {"Bold": True, "Italic": True}},
123 | sanitize=True
124 | )
125 |
126 | For legacy configurations (without extensions), convert to the extension mechanism with sanitization:
127 |
128 | .. code-block:: python
129 |
130 | # From this:
131 | content = ProseEditorField(config={"types": ["Bold", "Italic"]})
132 |
133 | # To this:
134 | content = ProseEditorField(
135 | config={"extensions": {"Bold": True, "Italic": True}},
136 | sanitize=True
137 | )
138 |
139 | Running the Checks
140 | ------------------
141 |
142 | System checks run automatically during normal Django operation, but you can also run them manually:
143 |
144 | .. code-block:: shell
145 |
146 | python manage.py check django_prose_editor
147 |
148 | This will display any warnings or errors specific to the django-prose-editor application.
149 |
--------------------------------------------------------------------------------
/src/utils.js:
--------------------------------------------------------------------------------
1 | // Global counter to support unique IDs for dialog form elements
2 | let dialogId = 0
3 |
4 | export const crel = (tagName, attributes = null, children = []) => {
5 | const dom = document.createElement(tagName)
6 | dom.append(...children)
7 | if (attributes) {
8 | for (const [name, value] of Object.entries(attributes)) {
9 | if (/^data-|^aria-|^role/.test(name)) dom.setAttribute(name, value)
10 | else dom[name] = value
11 | }
12 | }
13 | return dom
14 | }
15 |
16 | export const gettext = window.gettext || ((s) => s)
17 |
18 | const formFieldForProperty = (name, config, attrValue, id) => {
19 | const label = crel("label", {
20 | htmlFor: id,
21 | textContent: config.title || name,
22 | })
23 | if (config.description) {
24 | label.append(
25 | crel("span", {
26 | className: "prose-editor-help",
27 | textContent: config.description,
28 | }),
29 | )
30 | }
31 | const defaultValue =
32 | typeof config.default === "function" ? config.default() : config.default
33 | const value = attrValue || defaultValue || ""
34 | let widget
35 |
36 | if (config.type === "boolean") {
37 | return crel("div", { className: "prose-editor-dialog-field" }, [
38 | crel("input", { id, name, type: "checkbox", checked: !!value }),
39 | label,
40 | ])
41 | }
42 | if (config.format === "textarea") {
43 | const textarea = crel("textarea", { id, name, value, cols: 80, rows: 3 })
44 | widget = crel(
45 | "div",
46 | { className: "prose-editor-grow-wrap", "data-value": textarea.value },
47 | [textarea],
48 | )
49 | textarea.addEventListener("input", () => {
50 | widget.dataset.value = textarea.value
51 | })
52 | } else if (config.enum) {
53 | widget = crel(
54 | "select",
55 | { id, name, value },
56 | config.enum.map((option) => crel("option", { textContent: option })),
57 | )
58 | } else {
59 | // Create input with appropriate attributes
60 | const attrs = {
61 | id,
62 | name,
63 | value,
64 | type: config.format || "text",
65 | size: 50,
66 | }
67 |
68 | // Add validation attributes if provided
69 | if (config.min !== undefined) attrs.min = config.min
70 | if (config.max !== undefined) attrs.max = config.max
71 | if (config.required) attrs.required = "required"
72 |
73 | widget = crel("input", attrs)
74 | }
75 |
76 | return crel("div", { className: "prose-editor-dialog-field" }, [
77 | label,
78 | widget,
79 | ])
80 | }
81 |
82 | const valueForFormField = (name, config, form) => {
83 | if (config.type === "boolean") {
84 | return !!form[name].checked
85 | }
86 | return form[name].value
87 | }
88 |
89 | export const updateAttrsDialog =
90 | (properties, options = {}) =>
91 | (editor, attrs) => {
92 | return new Promise((resolve) => {
93 | const submit = crel("button", {
94 | type: "submit",
95 | textContent: options.submitText || gettext("Update"),
96 | })
97 | const cancel = crel("button", {
98 | type: "button",
99 | value: "cancel",
100 | textContent: gettext("Cancel"),
101 | })
102 |
103 | // Create form elements
104 | const formElements = []
105 |
106 | // Add title if provided
107 | if (options.title) {
108 | formElements.push(
109 | crel("h3", {
110 | className: "prose-editor-dialog-title",
111 | textContent: options.title,
112 | }),
113 | )
114 | }
115 |
116 | const prefix = `prose-editor-${++dialogId}-`
117 |
118 | // Add form fields with dynamic enum support
119 | formElements.push(
120 | ...Object.entries(properties).map(([name, config], idx) =>
121 | formFieldForProperty(name, config, attrs[name], `${prefix}${idx}`),
122 | ),
123 | )
124 |
125 | // Create action buttons container
126 | const buttonContainer = crel("div", {
127 | className: "prose-editor-dialog-buttons",
128 | })
129 |
130 | // Add custom action buttons if provided
131 | if (options.actions) {
132 | for (const action of options.actions) {
133 | const actionButton = crel("button", {
134 | type: "button",
135 | textContent: action.text,
136 | className: action.className || "",
137 | })
138 |
139 | actionButton.addEventListener("click", (e) => {
140 | e.preventDefault()
141 | // Get current form values for the action
142 | const currentValues = Object.fromEntries(
143 | Object.entries(properties).map(([name, config]) => [
144 | name,
145 | valueForFormField(name, config, form),
146 | ]),
147 | )
148 | // Call the action with current form values and form reference
149 | action.handler(currentValues, form, editor)
150 | })
151 |
152 | buttonContainer.append(actionButton)
153 | }
154 | }
155 |
156 | // Add primary action buttons
157 | buttonContainer.append(submit, cancel)
158 | formElements.push(buttonContainer)
159 |
160 | const div = crel("div", {}, [
161 | crel("dialog", { className: "prose-editor-dialog" }, [
162 | crel("form", {}, formElements),
163 | ]),
164 | ])
165 |
166 | editor.view.dom.parentElement.append(div)
167 | const dialog = div.querySelector("dialog")
168 | const form = div.querySelector("form")
169 |
170 | cancel.addEventListener("click", () => {
171 | dialog.close()
172 | })
173 | dialog.addEventListener("close", () => {
174 | div.remove()
175 | resolve(null)
176 | })
177 | submit.addEventListener("click", (e) => {
178 | e.preventDefault()
179 | if (form.reportValidity()) {
180 | div.remove()
181 | resolve(
182 | Object.fromEntries(
183 | Object.entries(properties).map(([name, config]) => [
184 | name,
185 | valueForFormField(name, config, form),
186 | ]),
187 | ),
188 | )
189 | }
190 | })
191 | dialog.showModal()
192 | })
193 | }
194 |
--------------------------------------------------------------------------------
/docs/textclass.rst:
--------------------------------------------------------------------------------
1 | TextClass Extension
2 | ===================
3 |
4 | The TextClass extension allows you to apply arbitrary CSS classes to text sections using ```` tags. This provides a clean, semantic way to style content without relying on inline styles.
5 |
6 | Unlike Tiptap's TextStyle extension which uses inline ``style`` attributes, TextClass uses CSS classes, making it more maintainable and allowing for consistent theming across your application.
7 |
8 | Basic Usage
9 | -----------
10 |
11 | To use the TextClass extension, configure it with a list of allowed CSS classes. Each class can be specified as:
12 |
13 | - A string (class name and display title will be the same)
14 | - An object with ``className`` and ``title`` properties for custom display names
15 |
16 | .. code-block:: python
17 |
18 | from django_prose_editor.fields import ProseEditorField
19 |
20 | class Article(models.Model):
21 | content = ProseEditorField(
22 | extensions={
23 | "Bold": True,
24 | "Italic": True,
25 | "TextClass": {
26 | "cssClasses": [
27 | "highlight", # String format
28 | "important",
29 | {"className": "subtle", "title": "Subtle Text"}, # Object format
30 | {"className": "warning", "title": "Warning"}
31 | ]
32 | }
33 | }
34 | )
35 |
36 | JavaScript Configuration
37 | ------------------------
38 |
39 | When creating custom presets, you can configure the TextClass extension in JavaScript:
40 |
41 | .. code-block:: javascript
42 |
43 | import { TextClass } from "django-prose-editor/editor"
44 |
45 | // Configure with allowed CSS classes (string format)
46 | TextClass.configure({
47 | cssClasses: ["highlight", "important", "subtle", "warning"]
48 | })
49 |
50 | // Configure with custom display titles (object format)
51 | TextClass.configure({
52 | cssClasses: [
53 | "highlight",
54 | { className: "important", title: "Important Text" },
55 | { className: "subtle", title: "Subtle Text" },
56 | { className: "warning", title: "Warning" }
57 | ]
58 | })
59 |
60 | Menu Integration
61 | ----------------
62 |
63 | When configured with CSS classes, TextClass automatically adds a dropdown menu to the editor with options for each class. The dropdown includes:
64 |
65 | - **default**: Removes any applied text class (returns to normal text)
66 | - Each configured CSS class as a selectable option
67 |
68 | When classes are configured as objects with ``title`` properties, the menu will display the custom title while applying the specified ``className``. For string-configured classes, the class name serves as both the CSS class and display title.
69 |
70 | The menu items appear in the ``textClass`` group and are typically displayed as a dropdown in the default menu layout.
71 |
72 | Commands
73 | --------
74 |
75 | The TextClass extension provides these commands:
76 |
77 | .. code-block:: javascript
78 |
79 | // Apply a CSS class to selected text
80 | editor.commands.setTextClass("highlight")
81 |
82 | // Remove text class from selected text
83 | editor.commands.unsetTextClass()
84 |
85 | // Check if text has a specific class applied
86 | editor.isActive("textClass", { class: "highlight" })
87 |
88 | HTML Output
89 | -----------
90 |
91 | The extension generates clean HTML with CSS classes:
92 |
93 | .. code-block:: html
94 |
95 | This is highlighted text in a paragraph.
96 | This text has warning styling applied.
97 |
98 | Sanitization
99 | ------------
100 |
101 | When using server-side sanitization, the TextClass extension automatically configures the sanitizer to allow ```` tags with ``class`` attributes.
102 |
103 | Styling
104 | -------
105 |
106 | Define CSS rules in your stylesheet to style the configured classes:
107 |
108 | .. code-block:: css
109 |
110 | .ProseMirror .highlight {
111 | background-color: yellow;
112 | padding: 2px 4px;
113 | border-radius: 3px;
114 | }
115 |
116 | .ProseMirror .important {
117 | font-weight: bold;
118 | color: #d32f2f;
119 | }
120 |
121 | .ProseMirror .subtle {
122 | opacity: 0.7;
123 | font-style: italic;
124 | }
125 |
126 | .ProseMirror .warning {
127 | background-color: #fff3cd;
128 | color: #856404;
129 | padding: 2px 4px;
130 | border-radius: 3px;
131 | border: 1px solid #ffeaa7;
132 | }
133 |
134 | Example Use Cases
135 | -----------------
136 |
137 | **Content Highlighting**
138 | Mark important information, key terms, or concepts that need visual emphasis.
139 |
140 | **Semantic Markup**
141 | Apply semantic classes like ``legal-disclaimer``, ``technical-term``, ``brand-name`` for consistent styling.
142 |
143 | **Theme Support**
144 | Use classes that change appearance based on your site's theme (light/dark mode).
145 |
146 | **Content Types**
147 | Distinguish different types of content like ``code-snippet``, ``file-path``, ``ui-element``.
148 |
149 | Best Practices
150 | --------------
151 |
152 | 1. **Use Semantic Class Names**: Choose descriptive names that describe the content's meaning, not its appearance
153 | 2. **Limit Available Classes**: Only provide classes that are actually needed to keep the UI clean
154 | 3. **Define CSS Consistently**: Ensure all configured classes have corresponding CSS rules
155 | 4. **Consider Accessibility**: Use sufficient color contrast and don't rely solely on color for meaning
156 | 5. **Document Classes**: Maintain documentation of available classes for content creators
157 |
158 | Comparison with TextStyle
159 | -------------------------
160 |
161 | TextClass is preferred over Tiptap's TextStyle extension because:
162 |
163 | - **Maintainability**: CSS classes are easier to update than inline styles
164 | - **Consistency**: Classes ensure uniform styling across content
165 | - **Flexibility**: Styles can change based on context (themes, responsive design)
166 | - **Security**: Class names are validated, preventing arbitrary style injection
167 | - **Performance**: CSS classes are more efficient than inline styles
168 |
--------------------------------------------------------------------------------
/tests/testapp/test_checks.py:
--------------------------------------------------------------------------------
1 | from django.core.checks import Error, Warning
2 | from django.db import models
3 | from django.test import SimpleTestCase, override_settings
4 |
5 | from django_prose_editor.checks import (
6 | check_extensions_parameter,
7 | check_js_preset_configuration,
8 | check_sanitization_enabled,
9 | )
10 | from django_prose_editor.fields import ProseEditorField
11 |
12 |
13 | class ChecksTests(SimpleTestCase):
14 | @override_settings(DJANGO_PROSE_EDITOR_PRESETS={})
15 | def test_no_default_preset_override(self):
16 | """Test that no errors are returned when 'default' preset is not overridden."""
17 | errors = check_js_preset_configuration(None)
18 | assert errors == []
19 |
20 | @override_settings(DJANGO_PROSE_EDITOR_PRESETS={"default": []})
21 | def test_default_preset_override(self):
22 | """Test that an error is returned when 'default' preset is overridden."""
23 | errors = check_js_preset_configuration(None)
24 | assert len(errors) == 1
25 | assert isinstance(errors[0], Error)
26 | assert (
27 | errors[0].msg
28 | == 'Overriding the "default" preset in DJANGO_PROSE_EDITOR_PRESETS is not allowed.'
29 | )
30 | assert errors[0].id == "django_prose_editor.E001"
31 |
32 | def test_config_deprecation_system_check(self):
33 | """Test that using the 'config' parameter is caught by system checks."""
34 | # Run the check function
35 | warnings = check_extensions_parameter([])
36 |
37 | # We expect warnings for each model that uses legacy config
38 | expected_models = [
39 | "ProseEditorModel",
40 | "SanitizedProseEditorModel",
41 | "TableProseEditorModel",
42 | ]
43 |
44 | # Check that we have at least the expected number of warnings
45 | assert len(warnings) >= len(expected_models), (
46 | f"Expected at least {len(expected_models)} warnings, got {len(warnings)}"
47 | )
48 |
49 | # For each expected model, make sure there's a corresponding warning
50 | for model_name in expected_models:
51 | model_warnings = [w for w in warnings if w.obj and model_name in w.obj]
52 | assert len(model_warnings) > 0, (
53 | f"No deprecation warning found for {model_name}"
54 | )
55 |
56 | # Verify the warning properties for one of them
57 | if model_name == "TableProseEditorModel":
58 | warning = model_warnings[0]
59 | assert isinstance(warning, Warning)
60 | assert warning.id == "django_prose_editor.W001"
61 | assert "legacy configuration format" in warning.msg
62 | # self.assertIn("extensions", warning.hint)
63 |
64 | def test_sanitization_check(self):
65 | """Test that all ProseEditorField instances without sanitization are caught."""
66 | # Run the check function on existing models
67 | warnings = check_sanitization_enabled([])
68 |
69 | # We expect warnings for ProseEditorModel since it doesn't have sanitization
70 | # but not for SanitizedProseEditorModel (has sanitization) or ConfigurableProseEditorModel (has sanitize=True)
71 | warning_objects = [w.obj for w in warnings]
72 |
73 | # Check expected warnings
74 | expected_warnings = any(
75 | "ProseEditorModel.description" in obj for obj in warning_objects
76 | )
77 | assert expected_warnings, "No warning for ProseEditorModel without sanitization"
78 |
79 | # Check unexpected warnings
80 | assert not any("SanitizedProseEditorModel" in obj for obj in warning_objects), (
81 | "Unexpected warning for SanitizedProseEditorModel which should have sanitization"
82 | )
83 | assert not any(
84 | "ConfigurableProseEditorModel" in obj for obj in warning_objects
85 | ), "Unexpected warning for ConfigurableProseEditorModel which has sanitize=True"
86 |
87 | # Test with different field configurations using a synthetic model
88 | class TestModel(models.Model):
89 | class Meta:
90 | app_label = "test_app_never_installed"
91 |
92 | def __str__(self):
93 | return ""
94 |
95 | # Fields with different configurations, all without sanitization
96 | with_extensions = ProseEditorField(
97 | config={"extensions": {"Bold": True}}, sanitize=False
98 | )
99 |
100 | legacy_config = ProseEditorField(config={"types": ["Bold"]}, sanitize=False)
101 |
102 | no_config = ProseEditorField(sanitize=False)
103 |
104 | # Manually add our test model to the models to check
105 | warnings = check_sanitization_enabled(
106 | [type("AppConfig", (), {"get_models": lambda: [TestModel]})]
107 | )
108 |
109 | # Check that we got warnings for all three fields
110 | assert len(warnings) == 3
111 |
112 | # Check extension field warning
113 | extension_warnings = [
114 | w
115 | for w in warnings
116 | if "test_app_never_installed.TestModel.with_extensions" in w.obj
117 | ]
118 | assert len(extension_warnings) == 1
119 | assert "using extensions without sanitization" in extension_warnings[0].msg
120 | assert "matches your configured extensions" in extension_warnings[0].hint
121 |
122 | # Check legacy config field warning
123 | legacy_warnings = [
124 | w
125 | for w in warnings
126 | if "test_app_never_installed.TestModel.legacy_config" in w.obj
127 | ]
128 | assert len(legacy_warnings) == 1
129 | assert "doesn't have sanitization enabled" in legacy_warnings[0].msg
130 | assert "extensions mechanism with sanitize=True" in legacy_warnings[0].hint
131 |
132 | # Check no config field warning
133 | no_config_warnings = [
134 | w
135 | for w in warnings
136 | if "test_app_never_installed.TestModel.no_config" in w.obj
137 | ]
138 | assert len(no_config_warnings) == 1
139 | assert "doesn't have sanitization enabled" in no_config_warnings[0].msg
140 | assert "extensions mechanism with sanitize=True" in no_config_warnings[0].hint
141 |
--------------------------------------------------------------------------------
/django_prose_editor/fields.py:
--------------------------------------------------------------------------------
1 | import re
2 |
3 | from django import forms
4 | from django.contrib.admin import widgets
5 | from django.db import models
6 | from django.utils.html import strip_tags
7 | from django.utils.text import Truncator
8 |
9 | from django_prose_editor.config import (
10 | allowlist_from_extensions,
11 | expand_extensions,
12 | )
13 | from django_prose_editor.widgets import AdminProseEditorWidget, ProseEditorWidget
14 |
15 |
16 | def _actually_empty(x):
17 | """
18 | ProseMirror's schema always adds at least one empty paragraph
19 |
20 | We want empty fields to actually be empty strings so that those field
21 | values evaluate as ``False`` in a boolean context.
22 | """
23 | if re.match(r"^<(?P\w+)>(?P=tag)>$", x):
24 | return ""
25 | return x
26 |
27 |
28 | def _identity(x):
29 | return x
30 |
31 |
32 | def create_sanitizer(extensions):
33 | """Create a sanitizer function based on extension configuration."""
34 | try:
35 | import nh3 # noqa: PLC0415
36 | except ImportError:
37 | raise ImportError(
38 | "You need to install nh3 to use automatic sanitization. "
39 | "Install django-prose-editor[sanitize] or pip install nh3"
40 | )
41 |
42 | nh3_kwargs = allowlist_from_extensions(expand_extensions(extensions))
43 | cleaner = nh3.Cleaner(**nh3_kwargs)
44 | return lambda html: _actually_empty(cleaner.clean(html))
45 |
46 |
47 | def _create_sanitizer(argument, config):
48 | if argument is False:
49 | return _actually_empty
50 |
51 | if argument is True:
52 | return create_sanitizer(config["extensions"])
53 |
54 | if isinstance(argument, (list, tuple)):
55 | argument = [
56 | fn(config["extensions"]) if fn == create_sanitizer else fn
57 | for fn in argument
58 | ]
59 |
60 | def apply(html):
61 | for fn in reversed(argument):
62 | html = fn(html)
63 | return html
64 |
65 | return apply
66 |
67 | return argument
68 |
69 |
70 | class ProseEditorField(models.TextField):
71 | """
72 | The field has two modes: Legacy mode and normal mode. Normal mode is
73 | activated by passing an ``config`` dict containing an ``extensions`` key.
74 | This mode is described below. See the README for the legacy mode.
75 |
76 | A field that uses a unified configuration for both editor extensions and sanitization.
77 |
78 | This field automatically synchronizes the editor capabilities with server-side
79 | sanitization rules, ensuring that what users can create in the editor matches
80 | what is allowed after sanitization.
81 |
82 | Args:
83 | config: Dictionary mapping extension names to their configuration
84 | preset: Optional JavaScript preset name to override the default
85 | sanitize: Whether to enable sanitization or a custom sanitizer function
86 | """
87 |
88 | def __init__(self, *args, **kwargs):
89 | self.config = kwargs.pop("config", {})
90 | if extensions := kwargs.pop("extensions", None):
91 | self.config["extensions"] = extensions
92 |
93 | if "extensions" in self.config:
94 | # Normal mode
95 | self.sanitize = _create_sanitizer(
96 | kwargs.pop("sanitize", False), self.config
97 | )
98 | self.preset = kwargs.pop("preset", "configurable")
99 |
100 | else:
101 | # Legacy mode
102 | self.sanitize = _create_sanitizer(kwargs.pop("sanitize", False), None)
103 | self.preset = kwargs.pop("preset", "default")
104 |
105 | super().__init__(*args, **kwargs)
106 |
107 | def clean(self, value, instance):
108 | return self.sanitize(super().clean(value, instance))
109 |
110 | def contribute_to_class(self, cls, name, **kwargs):
111 | """Add a ``get_*_excerpt`` method to models which returns a
112 | de-HTML-ified excerpt of the contents of this field"""
113 | super().contribute_to_class(cls, name, **kwargs)
114 | setattr(
115 | cls,
116 | f"get_{name}_excerpt",
117 | lambda self, words=10, truncate=" ...": Truncator(
118 | strip_tags(getattr(self, name))
119 | ).words(words, truncate=truncate),
120 | )
121 |
122 | def deconstruct(self):
123 | name, _path, args, kwargs = super().deconstruct()
124 | return (name, "django.db.models.TextField", args, kwargs)
125 |
126 | def formfield(self, **kwargs):
127 | defaults = {
128 | "config": self.config,
129 | "form_class": ProseEditorFormField,
130 | "preset": self.preset,
131 | } | kwargs
132 | return super().formfield(**defaults)
133 |
134 |
135 | def _is(widget, widget_class):
136 | return (
137 | issubclass(widget, widget_class)
138 | if isinstance(widget, type)
139 | else isinstance(widget, widget_class)
140 | )
141 |
142 |
143 | class ProseEditorFormField(forms.CharField):
144 | widget = ProseEditorWidget
145 |
146 | def __init__(self, *args, **kwargs):
147 | self.config = kwargs.pop("config", {})
148 | if extensions := kwargs.pop("extensions", None):
149 | self.config["extensions"] = extensions
150 |
151 | if "extensions" in self.config:
152 | # Normal mode
153 | self.sanitize = _create_sanitizer(
154 | kwargs.pop("sanitize", _identity), self.config
155 | )
156 | self.preset = kwargs.pop("preset", "configurable")
157 |
158 | else:
159 | # Legacy mode
160 | self.sanitize = kwargs.pop("sanitize", _identity)
161 | self.preset = kwargs.pop("preset", "default")
162 |
163 | widget = kwargs.get("widget")
164 |
165 | # We don't know if widget is set, and if it is, we do not know if it is
166 | # a class or an instance of the widget. The following if statement
167 | # should take all possibilities into account.
168 | if widget and _is(widget, widgets.AdminTextareaWidget):
169 | kwargs["widget"] = AdminProseEditorWidget
170 | elif not widget or not _is(widget, ProseEditorWidget):
171 | kwargs["widget"] = ProseEditorWidget
172 |
173 | super().__init__(*args, **kwargs)
174 | self.widget.config = self.config
175 | self.widget.preset = self.preset
176 |
177 | def clean(self, value):
178 | return self.sanitize(super().clean(value))
179 |
--------------------------------------------------------------------------------
/src/table.js:
--------------------------------------------------------------------------------
1 | import { Table as TiptapTable } from "@tiptap/extension-table"
2 |
3 | import { gettext, updateAttrsDialog } from "./utils.js"
4 |
5 | const tableDialog = updateAttrsDialog(
6 | {
7 | rows: {
8 | type: "number",
9 | title: gettext("Rows"),
10 | format: "number",
11 | default: "3",
12 | min: "1",
13 | max: "100",
14 | },
15 | cols: {
16 | type: "number",
17 | title: gettext("Columns"),
18 | format: "number",
19 | default: "3",
20 | min: "1",
21 | max: "100",
22 | },
23 | withHeaderRow: {
24 | title: gettext("Include header row"),
25 | enum: ["Yes", "No"],
26 | default: "No",
27 | },
28 | },
29 | {
30 | title: gettext("Table Properties"),
31 | submitText: gettext("Insert Table"),
32 | },
33 | )
34 |
35 | export const Table = TiptapTable.extend({
36 | addMenuItems({ editor, buttons, menu }) {
37 | defineTableMenuItems({ editor, buttons, menu })
38 | },
39 |
40 | addCommands() {
41 | return {
42 | ...this.parent?.(),
43 | insertTableWithOptions:
44 | () =>
45 | ({ editor }) => {
46 | // Show table configuration dialog
47 | tableDialog(editor, {
48 | rows: "3",
49 | cols: "3",
50 | withHeaderRow: "No",
51 | }).then((attrs) => {
52 | if (attrs) {
53 | const config = {
54 | rows: Number.parseInt(attrs.rows, 10) || 3,
55 | cols: Number.parseInt(attrs.cols, 10) || 3,
56 | withHeaderRow: attrs.withHeaderRow === "Yes",
57 | }
58 |
59 | // Insert table with the configured options
60 | editor.chain().focus().insertTable(config).run()
61 | }
62 | })
63 | },
64 | }
65 | },
66 | })
67 |
68 | function defineTableMenuItems({ buttons, menu }) {
69 | const tableManipulationItem = (name, command, button) => {
70 | menu.defineItem({
71 | name,
72 | groups: "table",
73 | command,
74 | button,
75 | hidden(editor) {
76 | return !editor.isActive("table")
77 | },
78 | })
79 | }
80 |
81 | menu.defineItem({
82 | name: "table",
83 | groups: "table",
84 | command(editor) {
85 | editor.chain().focus().insertTableWithOptions().run()
86 | },
87 | button: buttons.material("grid_on", "Insert table"),
88 | })
89 |
90 | tableManipulationItem(
91 | "table:addColumnAfter",
92 | (editor) => {
93 | editor.chain().focus().addColumnAfter().run()
94 | },
95 | buttons.svg(
96 | ``,
103 | "Add column",
104 | ),
105 | )
106 |
107 | tableManipulationItem(
108 | "table:deleteColumn",
109 | (editor) => {
110 | editor.chain().focus().deleteColumn().run()
111 | },
112 | buttons.svg(
113 | ``,
119 | "Delete column",
120 | ),
121 | )
122 |
123 | tableManipulationItem(
124 | "table:addRowAfter",
125 | (editor) => {
126 | editor.chain().focus().addRowAfter().run()
127 | },
128 | buttons.svg(
129 | ``,
136 | "Add row",
137 | ),
138 | )
139 |
140 | tableManipulationItem(
141 | "table:deleteRow",
142 | (editor) => {
143 | editor.chain().focus().deleteRow().run()
144 | },
145 | buttons.svg(
146 | ``,
152 | "Delete row",
153 | ),
154 | )
155 |
156 | tableManipulationItem(
157 | "table:mergeCells",
158 | (editor) => {
159 | editor.chain().focus().mergeCells().run()
160 | },
161 | buttons.material("call_merge", "Merge cells"),
162 | )
163 |
164 | tableManipulationItem(
165 | "table:splitCell",
166 | (editor) => {
167 | editor.chain().focus().splitCell().run()
168 | },
169 | buttons.material("call_split", "Split cell"),
170 | )
171 |
172 | // Toggle header cell (works on selected cells or current cell)
173 | tableManipulationItem(
174 | "table:toggleHeaderCell",
175 | (editor) => {
176 | editor.chain().focus().toggleHeaderCell().run()
177 | },
178 | buttons.svg(
179 | ``,
186 | "Toggle header cell",
187 | ),
188 | )
189 | }
190 |
--------------------------------------------------------------------------------
/src/overrides.css:
--------------------------------------------------------------------------------
1 | /* Thanks, https://github.com/django-tiptap/django_tiptap/blob/main/django_tiptap/static/django_tiptap/css/styles.css */
2 |
3 | .ProseMirror {
4 | padding: 1rem 1.75rem;
5 | outline: none !important;
6 | font-family:
7 | -apple-system, blinkmacsystemfont, "Segoe UI", roboto, oxygen, ubuntu,
8 | cantarell, "Open Sans", "Helvetica Neue", sans-serif;
9 | margin: 0 !important;
10 |
11 | color: var(--body-fg);
12 | background: var(--body-bg);
13 | }
14 |
15 | .ProseMirror * {
16 | color: inherit;
17 | background: inherit;
18 | }
19 |
20 | .ProseMirror p {
21 | font-size: 15px !important;
22 | }
23 |
24 | .ProseMirror h1 {
25 | display: block !important;
26 | font-size: 2em !important;
27 | margin-block-start: 0.67em !important;
28 | margin-block-end: 0.67em !important;
29 | margin-inline-start: 0px !important;
30 | margin-inline-end: 0px !important;
31 | font-weight: bold !important;
32 | line-height: 1.2 !important;
33 | }
34 |
35 | .ProseMirror h2 {
36 | padding: 0;
37 | display: block !important;
38 | font-size: 1.5em !important;
39 | margin-block-start: 0.83em !important;
40 | margin-block-end: 0.83em !important;
41 | margin-inline-start: 0px !important;
42 | margin-inline-end: 0px !important;
43 | font-weight: bold !important;
44 | line-height: 1.2 !important;
45 | }
46 |
47 | .ProseMirror h3 {
48 | display: block !important;
49 | font-size: 1.17em !important;
50 | margin-block-start: 1em !important;
51 | margin-block-end: 1em !important;
52 | margin-inline-start: 0px !important;
53 | margin-inline-end: 0px !important;
54 | font-weight: bold !important;
55 | padding: 0 !important;
56 | line-height: 1.2 !important;
57 | }
58 |
59 | .ProseMirror > * + * {
60 | margin-top: 0.75em !important;
61 | }
62 |
63 | .ProseMirror p.is-editor-empty:first-child::before {
64 | content: attr(data-placeholder);
65 | float: left;
66 | color: #a8a8a8;
67 | pointer-events: none;
68 | height: 0;
69 | }
70 |
71 | /* List styling improvements */
72 | .ProseMirror ul {
73 | list-style-type: disc !important;
74 | }
75 |
76 | /* Fix for grappelli removing list styles */
77 | .ProseMirror ul {
78 | list-style-type: disc !important;
79 | }
80 |
81 | .ProseMirror ol:not([type]) {
82 | list-style-type: decimal !important;
83 | }
84 |
85 | .ProseMirror ol[type="a"],
86 | .ProseMirror ol[data-type="lower-alpha"] {
87 | list-style-type: lower-alpha !important;
88 | }
89 |
90 | .ProseMirror ol[data-type="upper-alpha"] {
91 | list-style-type: upper-alpha !important;
92 | }
93 |
94 | .ProseMirror ol[type="i"],
95 | .ProseMirror ol[data-type="lower-roman"] {
96 | list-style-type: lower-roman !important;
97 | }
98 |
99 | .ProseMirror ol[data-type="upper-roman"] {
100 | list-style-type: upper-roman !important;
101 | }
102 |
103 | .ProseMirror li {
104 | list-style: inherit !important;
105 | padding: 0;
106 | margin: 0;
107 | }
108 |
109 | .ProseMirror ol,
110 | .ProseMirror ul {
111 | padding-left: 2.5em !important; /* Bring markers closer to content area */
112 | margin: 0.3em 0 !important; /* Reduce vertical margins */
113 | }
114 |
115 | .ProseMirror ol li,
116 | .ProseMirror ul li {
117 | position: relative !important;
118 | margin: 0.1em 0 !important; /* Reduced space between list items */
119 | padding-left: 0 !important; /* No extra padding after marker */
120 | display: list-item !important; /* Explicit display for list items */
121 | }
122 |
123 | .ProseMirror ol li::marker,
124 | .ProseMirror ul li::marker {
125 | font-size: 15px !important;
126 | color: var(--body-fg) !important;
127 | }
128 |
129 | /* Nested list styling */
130 | .ProseMirror ol ol,
131 | .ProseMirror ul ul,
132 | .ProseMirror ol ul,
133 | .ProseMirror ul ol {
134 | margin: 0.1em 0 0.1em 0.5em !important; /* Tighter nested lists with less indent */
135 | }
136 |
137 | .ProseMirror h1,
138 | .ProseMirror h2,
139 | .ProseMirror h3,
140 | .ProseMirror h4,
141 | .ProseMirror h5,
142 | .ProseMirror h6 {
143 | line-height: 1.1;
144 | text-transform: none;
145 | padding: 0;
146 | color: var(--body-fg);
147 | background: none !important;
148 | border: none !important;
149 | }
150 |
151 | .ProseMirror pre {
152 | background: #0d0d0d !important;
153 | color: #fff !important;
154 | font-family: "JetBrainsMono", monospace !important;
155 | padding: 0.75rem 1rem !important;
156 | border-radius: 0.5rem !important;
157 | }
158 |
159 | .ProseMirror pre code {
160 | color: #fff !important;
161 | padding: 0 !important;
162 | background: none !important;
163 | font-size: 0.8rem !important;
164 | }
165 |
166 | .ProseMirror img {
167 | max-width: 100%;
168 | height: auto;
169 | }
170 |
171 | .ProseMirror blockquote {
172 | padding-left: 1rem;
173 | border-left: 2px solid rgba(13, 13, 13, 0.1);
174 | }
175 |
176 | .ProseMirror hr {
177 | border: none;
178 | border-top: 2px solid rgba(13, 13, 13, 0.1);
179 | margin: 2rem 0 !important;
180 | }
181 |
182 | .ProseMirror a {
183 | text-decoration: underline;
184 | }
185 |
186 | /* Table-specific styling */
187 | .ProseMirror table {
188 | border-collapse: collapse;
189 | table-layout: fixed;
190 | width: 100%;
191 | margin: 0;
192 | overflow: hidden;
193 | }
194 |
195 | .ProseMirror table td,
196 | .ProseMirror table th,
197 | .ProseMirror table[show_borders="false"]:hover td,
198 | .ProseMirror table[show_borders="false"]:hover th {
199 | min-width: 1em;
200 | border: 2px solid var(--border-color, #ced4da);
201 | padding: 3px 5px;
202 | vertical-align: top;
203 | box-sizing: border-box;
204 | position: relative;
205 | }
206 |
207 | .ProseMirror table[show_borders="false"] td,
208 | .ProseMirror table[show_borders="false"] th {
209 | border: none;
210 | box-sizing: border-box;
211 | }
212 |
213 | .ProseMirror table td > *,
214 | .ProseMirror table th > * {
215 | margin-bottom: 0;
216 | }
217 |
218 | .ProseMirror table th {
219 | font-weight: bold;
220 | text-align: left;
221 | }
222 |
223 | .ProseMirror table th p {
224 | font-weight: inherit;
225 | }
226 |
227 | .ProseMirror table .selectedCell:after {
228 | z-index: 2;
229 | position: absolute;
230 | content: "";
231 | left: 0;
232 | right: 0;
233 | top: 0;
234 | bottom: 0;
235 | background: rgba(200, 200, 255, 0.4);
236 | pointer-events: none;
237 | }
238 |
239 | .ProseMirror table .column-resize-handle {
240 | position: absolute;
241 | right: -2px;
242 | top: 0;
243 | bottom: -2px;
244 | width: 4px;
245 | background-color: #adf;
246 | pointer-events: none;
247 | }
248 |
249 | .ProseMirror label {
250 | display: inline;
251 | max-width: auto;
252 | min-width: 0;
253 | width: auto;
254 | }
255 |
256 | .tableWrapper {
257 | overflow-x: auto;
258 | }
259 |
260 | .resize-cursor {
261 | cursor: col-resize;
262 | }
263 |
264 | /* Copied from prosemirror-view/style/prosemirror.css because
265 | * tiptap's copy in @tiptap/core/src/style.css is missing some rules */
266 |
267 | /* See https://github.com/ProseMirror/prosemirror/issues/1421#issuecomment-1759320191 */
268 | .ProseMirror [draggable][contenteditable="false"] {
269 | user-select: text;
270 | }
271 |
272 | .ProseMirror-selectednode {
273 | outline: 2px solid var(--_a, #8cf);
274 | }
275 |
276 | /* Make sure li selections wrap around markers */
277 |
278 | li.ProseMirror-selectednode {
279 | outline: none;
280 | }
281 |
282 | li.ProseMirror-selectednode:after {
283 | content: "";
284 | position: absolute;
285 | left: -32px;
286 | right: -2px;
287 | top: -2px;
288 | bottom: -2px;
289 | border: 2px solid #8cf;
290 | pointer-events: none;
291 | }
292 |
--------------------------------------------------------------------------------
/tests/testapp/test_configurable.py:
--------------------------------------------------------------------------------
1 | """Tests for the new configurable prose editor field."""
2 |
3 | import json
4 |
5 | from django.forms.models import ModelForm
6 | from django.test import TestCase
7 |
8 | from django_prose_editor.config import DEFAULT_MENU_GROUPS
9 | from testapp.models import ConfigurableProseEditorModel
10 |
11 |
12 | class ConfigurableFormTestCase(TestCase):
13 | """Tests for the configurable field form rendering."""
14 |
15 | def test_extensions_explicit_configuration(self):
16 | """Test that extensions are configured as explicitly specified."""
17 |
18 | class TestForm(ModelForm):
19 | class Meta:
20 | model = ConfigurableProseEditorModel
21 | fields = ["description"]
22 |
23 | form = TestForm()
24 | widget = form.fields["description"].widget
25 |
26 | context = widget.get_context("description", "", {})
27 | config = json.loads(
28 | context["widget"]["attrs"]["data-django-prose-editor-configurable"]
29 | )
30 |
31 | # The original extensions should be there
32 | assert "Bold" in config["extensions"]
33 | assert "Italic" in config["extensions"]
34 | assert "Table" in config["extensions"]
35 |
36 | # The custom BlueBold extension should also be included
37 | assert "BlueBold" in config["extensions"]
38 |
39 | # ListItem should be there because it's explicitly configured
40 | assert "ListItem" in config["extensions"]
41 |
42 | # Table dependencies are now explicitly configured in the model
43 | assert "TableRow" in config["extensions"]
44 | assert "TableHeader" in config["extensions"]
45 | assert "TableCell" in config["extensions"]
46 |
47 | # Core extensions should always be included
48 | assert "Paragraph" in config["extensions"]
49 | assert "Document" in config["extensions"]
50 | assert "Text" in config["extensions"]
51 |
52 | def test_heading_levels_config(self):
53 | """Test that heading levels are properly passed to the widget."""
54 |
55 | class TestForm(ModelForm):
56 | class Meta:
57 | model = ConfigurableProseEditorModel
58 | fields = ["description"]
59 |
60 | form = TestForm()
61 | widget = form.fields["description"].widget
62 |
63 | # Get the expanded extensions from the widget attributes
64 |
65 | context = widget.get_context("description", "", {})
66 | config = json.loads(
67 | context["widget"]["attrs"]["data-django-prose-editor-configurable"]
68 | )
69 |
70 | # Check that Heading is in extensions with the proper configuration
71 | assert "Heading" in config["extensions"]
72 | assert config["extensions"]["Heading"]["levels"] == [1, 2, 3]
73 |
74 | assert config["extensions"] == widget.config["extensions"] | {
75 | "Document": True,
76 | "Dropcursor": True,
77 | "Gapcursor": True,
78 | "Paragraph": True,
79 | "Text": True,
80 | "Menu": {"groups": DEFAULT_MENU_GROUPS},
81 | "NoSpellCheck": True,
82 | # Enable history by default unless explicitly disabled
83 | "History": True,
84 | # No automatic dependencies are added
85 | }
86 |
87 | def test_sanitization_works(self):
88 | """Test that basic sanitization works correctly with ConfigurableProseEditorField."""
89 |
90 | # Create a form instance
91 | class TestForm(ModelForm):
92 | class Meta:
93 | model = ConfigurableProseEditorModel
94 | fields = ["description"]
95 |
96 | # Test that script tags are removed
97 | html = "Text with
"
98 | form = TestForm(data={"description": html})
99 | assert form.is_valid()
100 |
101 | # Create and save the model instance to trigger sanitization
102 | instance = form.save()
103 | sanitized = instance.description
104 | assert "Text with
" == sanitized, "Script tags should be removed"
105 |
106 | # Test that basic formatting is preserved
107 | html = "Bold and italic text
"
108 | form = TestForm(data={"description": html})
109 | assert form.is_valid()
110 | instance = form.save()
111 | sanitized = instance.description
112 | assert "Bold and italic text
" == sanitized, (
113 | "Standard formatting should be preserved"
114 | )
115 |
116 | # Test that table elements are preserved
117 | html = "Header Cell
"
118 | form = TestForm(data={"description": html})
119 | assert form.is_valid()
120 | instance = form.save()
121 | sanitized = instance.description
122 | assert (
123 | "Header Cell
"
124 | == sanitized
125 | ), "Table elements should be preserved with proper structure"
126 |
127 | # Heading levels are configured as [1, 2, 3] in the model
128 | # Check that h1, h2, h3 are preserved but h4 is filtered out
129 | html = "H1
H2
H3
H4
"
130 | form = TestForm(data={"description": html})
131 | assert form.is_valid()
132 | instance = form.save()
133 | instance.full_clean()
134 | sanitized = instance.description
135 |
136 | # Check that h1, h2, h3 tags are preserved
137 | assert "" in sanitized, "h1 should be preserved"
138 | assert "" in sanitized, "h2 should be preserved"
139 | assert "" in sanitized, "h3 should be preserved"
140 |
141 | # h4 should be removed since it's not in the allowed levels [1, 2, 3]
142 | assert "" not in sanitized, "h4 should be removed"
143 | # But the content should still be there
144 | assert "H4" in sanitized, "content from h4 should still exist"
145 |
146 | # Test that unsupported tags (sub, sup) are removed
147 | html = "
Normal text with subscript and superscript
"
148 | form = TestForm(data={"description": html})
149 | assert form.is_valid()
150 | instance = form.save()
151 | sanitized = instance.description
152 | assert "" not in sanitized, "sub tags should be removed"
153 | assert "" not in sanitized, "sup tags should be removed"
154 | assert "subscript" in sanitized, "content from sub should still exist"
155 | assert "superscript" in sanitized, "content from sup should still exist"
156 |
157 | # Test link sanitization - Link is not in the extensions list,
158 | # so it should be removed
159 | html = 'Link with attributes
'
160 | form = TestForm(data={"description": html})
161 | assert form.is_valid()
162 | instance = form.save()
163 | sanitized = instance.description
164 | # Link tag should be removed since it's not in the extensions
165 | assert "