django-visual-editor 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_visual_editor/__init__.py +13 -0
- django_visual_editor/apps.py +7 -0
- django_visual_editor/fields.py +42 -0
- django_visual_editor/migrations/0001_initial.py +54 -0
- django_visual_editor/migrations/__init__.py +1 -0
- django_visual_editor/models.py +33 -0
- django_visual_editor/static/django_visual_editor/css/editor.css +2 -0
- django_visual_editor/static/django_visual_editor/js/editor.bundle.js +1 -0
- django_visual_editor/templates/django_visual_editor/widget.html +6 -0
- django_visual_editor/urls.py +8 -0
- django_visual_editor/views.py +45 -0
- django_visual_editor/widgets.py +39 -0
- django_visual_editor-0.1.1.dist-info/METADATA +314 -0
- django_visual_editor-0.1.1.dist-info/RECORD +17 -0
- django_visual_editor-0.1.1.dist-info/WHEEL +5 -0
- django_visual_editor-0.1.1.dist-info/licenses/LICENSE +21 -0
- django_visual_editor-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Django Visual Editor - A rich text editor with image upload support
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
__version__ = "0.1.0"
|
|
6
|
+
|
|
7
|
+
default_app_config = "django_visual_editor.apps.DjangoVisualEditorConfig"
|
|
8
|
+
|
|
9
|
+
# Expose main components for easy import
|
|
10
|
+
from .fields import VisualEditorField
|
|
11
|
+
from .widgets import VisualEditorWidget
|
|
12
|
+
|
|
13
|
+
__all__ = ["VisualEditorField", "VisualEditorWidget"]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from django.db import models
|
|
2
|
+
from .widgets import VisualEditorWidget
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class VisualEditorField(models.TextField):
|
|
6
|
+
"""
|
|
7
|
+
A TextField that uses the VisualEditorWidget in forms.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
class BlogPost(models.Model):
|
|
11
|
+
content = VisualEditorField(
|
|
12
|
+
config={
|
|
13
|
+
'min_height': 400,
|
|
14
|
+
'placeholder': 'Start typing...',
|
|
15
|
+
}
|
|
16
|
+
)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, *args, **kwargs):
|
|
20
|
+
# Extract editor config from kwargs
|
|
21
|
+
self.editor_config = kwargs.pop("config", {})
|
|
22
|
+
super().__init__(*args, **kwargs)
|
|
23
|
+
|
|
24
|
+
def formfield(self, **kwargs):
|
|
25
|
+
"""
|
|
26
|
+
Override formfield to use VisualEditorWidget by default
|
|
27
|
+
"""
|
|
28
|
+
# Set the widget to VisualEditorWidget with config
|
|
29
|
+
kwargs["widget"] = VisualEditorWidget(config=self.editor_config)
|
|
30
|
+
return super().formfield(**kwargs)
|
|
31
|
+
|
|
32
|
+
def deconstruct(self):
|
|
33
|
+
"""
|
|
34
|
+
Tell Django how to serialize this field for migrations
|
|
35
|
+
"""
|
|
36
|
+
name, path, args, kwargs = super().deconstruct()
|
|
37
|
+
|
|
38
|
+
# Add editor_config to kwargs if it's not empty
|
|
39
|
+
if self.editor_config:
|
|
40
|
+
kwargs["config"] = self.editor_config
|
|
41
|
+
|
|
42
|
+
return name, path, args, kwargs
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Generated by Django 5.2.8 on 2025-11-08 19:55
|
|
2
|
+
|
|
3
|
+
import django.db.models.deletion
|
|
4
|
+
import django_visual_editor.models
|
|
5
|
+
from django.conf import settings
|
|
6
|
+
from django.db import migrations, models
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Migration(migrations.Migration):
|
|
10
|
+
|
|
11
|
+
initial = True
|
|
12
|
+
|
|
13
|
+
dependencies = [
|
|
14
|
+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
operations = [
|
|
18
|
+
migrations.CreateModel(
|
|
19
|
+
name="EditorImage",
|
|
20
|
+
fields=[
|
|
21
|
+
(
|
|
22
|
+
"id",
|
|
23
|
+
models.BigAutoField(
|
|
24
|
+
auto_created=True,
|
|
25
|
+
primary_key=True,
|
|
26
|
+
serialize=False,
|
|
27
|
+
verbose_name="ID",
|
|
28
|
+
),
|
|
29
|
+
),
|
|
30
|
+
(
|
|
31
|
+
"image",
|
|
32
|
+
models.ImageField(
|
|
33
|
+
upload_to=django_visual_editor.models.upload_editor_image
|
|
34
|
+
),
|
|
35
|
+
),
|
|
36
|
+
("uploaded_at", models.DateTimeField(auto_now_add=True)),
|
|
37
|
+
(
|
|
38
|
+
"uploaded_by",
|
|
39
|
+
models.ForeignKey(
|
|
40
|
+
blank=True,
|
|
41
|
+
null=True,
|
|
42
|
+
on_delete=django.db.models.deletion.SET_NULL,
|
|
43
|
+
related_name="uploaded_editor_images",
|
|
44
|
+
to=settings.AUTH_USER_MODEL,
|
|
45
|
+
),
|
|
46
|
+
),
|
|
47
|
+
],
|
|
48
|
+
options={
|
|
49
|
+
"verbose_name": "Editor Image",
|
|
50
|
+
"verbose_name_plural": "Editor Images",
|
|
51
|
+
"ordering": ["-uploaded_at"],
|
|
52
|
+
},
|
|
53
|
+
),
|
|
54
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Migrations directory
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from django.db import models
|
|
2
|
+
from django.conf import settings
|
|
3
|
+
import uuid
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def upload_editor_image(instance, filename):
|
|
8
|
+
"""Generate upload path for editor images"""
|
|
9
|
+
ext = filename.split(".")[-1]
|
|
10
|
+
filename = f"{uuid.uuid4()}.{ext}"
|
|
11
|
+
return os.path.join("editor_uploads", filename)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class EditorImage(models.Model):
|
|
15
|
+
"""Model to store uploaded images from the editor"""
|
|
16
|
+
|
|
17
|
+
image = models.ImageField(upload_to=upload_editor_image)
|
|
18
|
+
uploaded_at = models.DateTimeField(auto_now_add=True)
|
|
19
|
+
uploaded_by = models.ForeignKey(
|
|
20
|
+
settings.AUTH_USER_MODEL,
|
|
21
|
+
on_delete=models.SET_NULL,
|
|
22
|
+
null=True,
|
|
23
|
+
blank=True,
|
|
24
|
+
related_name="uploaded_editor_images",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
class Meta:
|
|
28
|
+
ordering = ["-uploaded_at"]
|
|
29
|
+
verbose_name = "Editor Image"
|
|
30
|
+
verbose_name_plural = "Editor Images"
|
|
31
|
+
|
|
32
|
+
def __str__(self):
|
|
33
|
+
return f"Image {self.id} - {self.uploaded_at}"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
(()=>{"use strict";var e={56:(e,t,n)=>{e.exports=function(e){var t=n.nc;t&&e.setAttribute("nonce",t)}},72:e=>{var t=[];function n(e){for(var n=-1,o=0;o<t.length;o++)if(t[o].identifier===e){n=o;break}return n}function o(e,o){for(var i={},r=[],s=0;s<e.length;s++){var l=e[s],c=o.base?l[0]+o.base:l[0],d=i[c]||0,m="".concat(c," ").concat(d);i[c]=d+1;var u=n(m),h={css:l[1],media:l[2],sourceMap:l[3],supports:l[4],layer:l[5]};if(-1!==u)t[u].references++,t[u].updater(h);else{var p=a(h,o);o.byIndex=s,t.splice(s,0,{identifier:m,updater:p,references:1})}r.push(m)}return r}function a(e,t){var n=t.domAPI(t);return n.update(e),function(t){if(t){if(t.css===e.css&&t.media===e.media&&t.sourceMap===e.sourceMap&&t.supports===e.supports&&t.layer===e.layer)return;n.update(e=t)}else n.remove()}}e.exports=function(e,a){var i=o(e=e||[],a=a||{});return function(e){e=e||[];for(var r=0;r<i.length;r++){var s=n(i[r]);t[s].references--}for(var l=o(e,a),c=0;c<i.length;c++){var d=n(i[c]);0===t[d].references&&(t[d].updater(),t.splice(d,1))}i=l}}},113:e=>{e.exports=function(e,t){if(t.styleSheet)t.styleSheet.cssText=e;else{for(;t.firstChild;)t.removeChild(t.firstChild);t.appendChild(document.createTextNode(e))}}},293:(e,t,n)=>{n.d(t,{A:()=>s});var o=n(601),a=n.n(o),i=n(314),r=n.n(i)()(a());r.push([e.id,"/* Visual Editor Container */\n.visual-editor-container {\n border: 1px solid #ddd;\n border-radius: 4px;\n background: #fff;\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;\n}\n\n/* Toolbar */\n.visual-editor-toolbar {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 4px;\n padding: 8px;\n background: #f5f5f5;\n border-bottom: 1px solid #ddd;\n border-radius: 4px 4px 0 0;\n}\n\n.toolbar-button {\n padding: 6px 10px;\n border: 1px solid #ccc;\n background: #fff;\n border-radius: 3px;\n cursor: pointer;\n font-size: 14px;\n line-height: 1;\n transition: all 0.2s;\n min-width: 32px;\n text-align: center;\n}\n\n.toolbar-button:hover {\n background: #e9e9e9;\n border-color: #999;\n}\n\n.toolbar-button:active {\n background: #d9d9d9;\n}\n\n.toolbar-button.active {\n background: #007bff;\n color: #fff;\n border-color: #0056b3;\n}\n\n.toolbar-separator {\n display: inline-block;\n width: 1px;\n height: 24px;\n margin: 0 4px;\n}\n\n.toolbar-select {\n padding: 6px 8px;\n border: 1px solid #ccc;\n background: #fff;\n border-radius: 3px;\n cursor: pointer;\n font-size: 14px;\n line-height: 1;\n transition: all 0.2s;\n max-width: 150px;\n}\n\n.toolbar-select:hover {\n background: #f9f9f9;\n border-color: #999;\n}\n\n.toolbar-select:focus {\n outline: none;\n border-color: #007bff;\n box-shadow: 0 0 0 2px rgba(0,123,255,0.1);\n}\n\n.toolbar-select:disabled {\n opacity: 0.4;\n cursor: not-allowed;\n background: #f5f5f5;\n}\n\n/* Editor Content Area */\n.visual-editor-content {\n min-height: 200px;\n max-height: 600px;\n padding: 12px;\n overflow-y: auto;\n outline: none;\n line-height: 1.6;\n color: #333;\n}\n\n.visual-editor-content:empty:before {\n content: attr(data-placeholder);\n color: #999;\n font-style: italic;\n}\n\n.visual-editor-content:focus {\n outline: none;\n}\n\n/* Content Styling */\n.visual-editor-content h1 {\n font-size: 2em;\n font-weight: bold;\n margin: 0.67em 0;\n}\n\n.visual-editor-content h2 {\n font-size: 1.5em;\n font-weight: bold;\n margin: 0.75em 0;\n}\n\n.visual-editor-content h3 {\n font-size: 1.17em;\n font-weight: bold;\n margin: 0.83em 0;\n}\n\n.visual-editor-content p {\n margin: 0.5em 0;\n}\n\n.visual-editor-content ul,\n.visual-editor-content ol {\n margin: 0.5em 0;\n padding-left: 2em;\n}\n\n.visual-editor-content li {\n margin: 0.25em 0;\n}\n\n.visual-editor-content img {\n max-width: 100%;\n height: auto;\n display: block;\n margin: 1em 0;\n border-radius: 4px;\n}\n\n.visual-editor-content a {\n color: #007bff;\n text-decoration: none;\n}\n\n.visual-editor-content a:hover {\n color: #0056b3;\n text-decoration: underline;\n}\n\n/* Code and pre */\n.visual-editor-content code {\n background: #f0f0f0;\n padding: 2px 6px;\n border-radius: 3px;\n font-family: 'Courier New', Consolas, Monaco, monospace;\n font-size: 0.9em;\n color: #c7254e;\n border: 1px solid #e1e1e8;\n}\n\n.visual-editor-content pre {\n background: #282c34;\n color: #abb2bf;\n padding: 16px;\n border-radius: 6px;\n overflow-x: auto;\n font-family: 'Courier New', Consolas, Monaco, monospace;\n font-size: 0.9em;\n line-height: 1.5;\n margin: 1em 0;\n border: 1px solid #3e4451;\n white-space: pre-wrap;\n word-wrap: break-word;\n}\n\n.visual-editor-content pre code {\n background: transparent;\n padding: 0;\n border: none;\n color: inherit;\n font-size: inherit;\n}\n\n/* Blockquote */\n.visual-editor-content blockquote {\n margin: 1em 0;\n padding-left: 1em;\n border-left: 3px solid #ddd;\n color: #666;\n font-style: italic;\n}\n\n/* Table */\n.visual-editor-content table {\n border-collapse: collapse;\n width: 100%;\n margin: 1em 0;\n}\n\n.visual-editor-content table td,\n.visual-editor-content table th {\n border: 1px solid #ddd;\n padding: 8px;\n text-align: left;\n}\n\n.visual-editor-content table th {\n background: #f5f5f5;\n font-weight: bold;\n}\n\n/* Scrollbar Styling */\n.visual-editor-content::-webkit-scrollbar {\n width: 8px;\n}\n\n.visual-editor-content::-webkit-scrollbar-track {\n background: #f1f1f1;\n}\n\n.visual-editor-content::-webkit-scrollbar-thumb {\n background: #888;\n border-radius: 4px;\n}\n\n.visual-editor-content::-webkit-scrollbar-thumb:hover {\n background: #555;\n}\n\n/* HTML Source Mode */\n.visual-editor-source {\n display: none;\n width: 100%;\n min-height: 200px;\n max-height: 600px;\n padding: 12px;\n font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'Courier New', monospace;\n font-size: 13px;\n line-height: 1.5;\n color: #333;\n background: #f8f8f8;\n border: none;\n resize: vertical;\n overflow-y: auto;\n white-space: pre;\n word-wrap: normal;\n overflow-wrap: normal;\n}\n\n.visual-editor-source:focus {\n outline: none;\n background: #fff;\n}\n\n/* Disabled buttons in source mode */\n.toolbar-button:disabled {\n opacity: 0.4;\n cursor: not-allowed;\n}\n\n.toolbar-button:disabled:hover {\n background: #fff;\n border-color: #ccc;\n}\n\n/* Image Controls */\n.image-control-panel {\n position: absolute;\n z-index: 1000;\n display: none;\n background: #fff;\n border: 1px solid #ddd;\n border-radius: 4px;\n box-shadow: 0 2px 8px rgba(0,0,0,0.15);\n padding: 8px;\n}\n\n.image-control-buttons {\n display: flex;\n gap: 4px;\n align-items: center;\n margin-bottom: 4px;\n}\n\n.image-control-btn {\n padding: 6px 10px;\n border: 1px solid #ccc;\n background: #fff;\n border-radius: 3px;\n cursor: pointer;\n font-size: 14px;\n line-height: 1;\n transition: all 0.2s;\n min-width: 32px;\n text-align: center;\n}\n\n.image-control-btn:hover {\n background: #e9e9e9;\n border-color: #999;\n}\n\n.image-control-btn:active {\n background: #d9d9d9;\n}\n\n.image-control-delete {\n background: #fee;\n border-color: #fcc;\n}\n\n.image-control-delete:hover {\n background: #fcc;\n border-color: #f99;\n}\n\n.image-control-separator {\n display: inline-block;\n width: 1px;\n height: 24px;\n background: #ccc;\n margin: 0 4px;\n}\n\n.image-resize-handle {\n position: absolute;\n bottom: 0;\n right: 0;\n width: 100%;\n height: 6px;\n background: linear-gradient(to bottom, transparent 0%, #007bff 100%);\n cursor: ew-resize;\n opacity: 0.5;\n transition: opacity 0.2s;\n}\n\n.image-resize-handle:hover {\n opacity: 1;\n}\n\n.image-selected {\n outline: 2px solid #007bff;\n outline-offset: 2px;\n}\n",""]);const s=r},314:e=>{e.exports=function(e){var t=[];return t.toString=function(){return this.map(function(t){var n="",o=void 0!==t[5];return t[4]&&(n+="@supports (".concat(t[4],") {")),t[2]&&(n+="@media ".concat(t[2]," {")),o&&(n+="@layer".concat(t[5].length>0?" ".concat(t[5]):""," {")),n+=e(t),o&&(n+="}"),t[2]&&(n+="}"),t[4]&&(n+="}"),n}).join("")},t.i=function(e,n,o,a,i){"string"==typeof e&&(e=[[null,e,void 0]]);var r={};if(o)for(var s=0;s<this.length;s++){var l=this[s][0];null!=l&&(r[l]=!0)}for(var c=0;c<e.length;c++){var d=[].concat(e[c]);o&&r[d[0]]||(void 0!==i&&(void 0===d[5]||(d[1]="@layer".concat(d[5].length>0?" ".concat(d[5]):""," {").concat(d[1],"}")),d[5]=i),n&&(d[2]?(d[1]="@media ".concat(d[2]," {").concat(d[1],"}"),d[2]=n):d[2]=n),a&&(d[4]?(d[1]="@supports (".concat(d[4],") {").concat(d[1],"}"),d[4]=a):d[4]="".concat(a)),t.push(d))}},t}},540:e=>{e.exports=function(e){var t=document.createElement("style");return e.setAttributes(t,e.attributes),e.insert(t,e.options),t}},601:e=>{e.exports=function(e){return e[1]}},659:e=>{var t={};e.exports=function(e,n){var o=function(e){if(void 0===t[e]){var n=document.querySelector(e);if(window.HTMLIFrameElement&&n instanceof window.HTMLIFrameElement)try{n=n.contentDocument.head}catch(e){n=null}t[e]=n}return t[e]}(e);if(!o)throw new Error("Couldn't find a style target. This probably means that the value for the 'insert' parameter is invalid.");o.appendChild(n)}},825:e=>{e.exports=function(e){if("undefined"==typeof document)return{update:function(){},remove:function(){}};var t=e.insertStyleElement(e);return{update:function(n){!function(e,t,n){var o="";n.supports&&(o+="@supports (".concat(n.supports,") {")),n.media&&(o+="@media ".concat(n.media," {"));var a=void 0!==n.layer;a&&(o+="@layer".concat(n.layer.length>0?" ".concat(n.layer):""," {")),o+=n.css,a&&(o+="}"),n.media&&(o+="}"),n.supports&&(o+="}");var i=n.sourceMap;i&&"undefined"!=typeof btoa&&(o+="\n/*# sourceMappingURL=data:application/json;base64,".concat(btoa(unescape(encodeURIComponent(JSON.stringify(i))))," */")),t.styleTagTransform(o,e,t.options)}(t,e,n)},remove:function(){!function(e){if(null===e.parentNode)return!1;e.parentNode.removeChild(e)}(t)}}}}},t={};function n(o){var a=t[o];if(void 0!==a)return a.exports;var i=t[o]={id:o,exports:{}};return e[o](i,i.exports,n),i.exports}n.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return n.d(t,{a:t}),t},n.d=(e,t)=>{for(var o in t)n.o(t,o)&&!n.o(e,o)&&Object.defineProperty(e,o,{enumerable:!0,get:t[o]})},n.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),n.nc=void 0;class o{constructor(e){this.buttons=[{name:"undo",icon:"↶",title:"Undo (Ctrl+Z)",command:"undo"},{name:"redo",icon:"↷",title:"Redo (Ctrl+Y)",command:"redo"},{name:"separator",icon:"|",title:"",command:""},{name:"fontName",icon:"",title:"Font Family",command:"fontName"},{name:"fontSize",icon:"",title:"Font Size",command:"fontSize"},{name:"separator",icon:"|",title:"",command:""},{name:"bold",icon:"<b>B</b>",title:"Bold (Ctrl+B)",command:"bold"},{name:"italic",icon:"<i>I</i>",title:"Italic (Ctrl+I)",command:"italic"},{name:"underline",icon:"<u>U</u>",title:"Underline (Ctrl+U)",command:"underline"},{name:"strikethrough",icon:"<s>S</s>",title:"Strikethrough",command:"strikeThrough"},{name:"separator",icon:"|",title:"",command:""},{name:"h1",icon:"H1",title:"Heading 1",command:"formatBlock",value:"h1"},{name:"h2",icon:"H2",title:"Heading 2",command:"formatBlock",value:"h2"},{name:"h3",icon:"H3",title:"Heading 3",command:"formatBlock",value:"h3"},{name:"separator",icon:"|",title:"",command:""},{name:"ul",icon:"• List",title:"Bullet List",command:"insertUnorderedList"},{name:"ol",icon:"1. List",title:"Numbered List",command:"insertOrderedList"},{name:"separator",icon:"|",title:"",command:""},{name:"code",icon:"<code>",title:"Inline Code",command:"insertCode"},{name:"codeBlock",icon:"{ }",title:"Code Block",command:"insertCodeBlock"},{name:"separator",icon:"|",title:"",command:""},{name:"image",icon:"🖼️",title:"Insert Image",command:"insertImage"},{name:"link",icon:"🔗",title:"Insert Link",command:"createLink"},{name:"separator",icon:"|",title:"",command:""},{name:"clear",icon:"✕",title:"Clear Formatting",command:"removeFormat"},{name:"separator",icon:"|",title:"",command:""},{name:"source",icon:"</>",title:"View HTML Source",command:"toggleSource"}],this.fontFamilies=[{name:"Default",value:""},{name:"Arial",value:"Arial, sans-serif"},{name:"Times New Roman",value:'"Times New Roman", serif'},{name:"Courier New",value:'"Courier New", monospace'},{name:"Georgia",value:"Georgia, serif"},{name:"Verdana",value:"Verdana, sans-serif"},{name:"Helvetica",value:"Helvetica, sans-serif"},{name:"Tahoma",value:"Tahoma, sans-serif"}],this.fontSizes=[{name:"10px",value:"1"},{name:"12px",value:"2"},{name:"14px",value:"3"},{name:"16px",value:"4"},{name:"18px",value:"5"},{name:"20px",value:"6"},{name:"24px",value:"7"}],this.onFontChange=()=>{},this.onSizeChange=()=>{};const t=document.getElementById(e);if(!t)throw new Error(`Toolbar container not found: ${e}`);this.element=t,this.render()}render(){this.element.className="visual-editor-toolbar",this.element.innerHTML="",this.buttons.forEach(e=>{if("separator"===e.name){const t=document.createElement("span");t.className="toolbar-separator",t.innerHTML=e.icon,this.element.appendChild(t)}else if("fontName"===e.name){const e=this.createFontFamilySelect();this.element.appendChild(e)}else if("fontSize"===e.name){const e=this.createFontSizeSelect();this.element.appendChild(e)}else{const t=document.createElement("button");t.type="button",t.className="toolbar-button",t.title=e.title,t.innerHTML=e.icon,t.dataset.command=e.command,e.value&&(t.dataset.value=e.value),this.element.appendChild(t)}})}createFontFamilySelect(){const e=document.createElement("select");return e.className="toolbar-select",e.title="Font Family",this.fontFamilies.forEach(t=>{const n=document.createElement("option");n.value=t.value,n.textContent=t.name,t.value&&(n.style.fontFamily=t.value),e.appendChild(n)}),e.addEventListener("change",e=>{const t=e.target;t.value&&this.onFontChange(t.value)}),e}createFontSizeSelect(){const e=document.createElement("select");e.className="toolbar-select",e.title="Font Size";const t=document.createElement("option");return t.value="",t.textContent="Size",e.appendChild(t),this.fontSizes.forEach(t=>{const n=document.createElement("option");n.value=t.value,n.textContent=t.name,e.appendChild(n)}),e.addEventListener("change",e=>{const t=e.target;t.value&&this.onSizeChange(t.value)}),e}onAction(e){this.element.addEventListener("mousedown",e=>{e.target.closest(".toolbar-button")&&e.preventDefault()}),this.element.addEventListener("click",t=>{const n=t.target.closest(".toolbar-button");if(n){t.preventDefault();const o=n.dataset.command,a=n.dataset.value;o&&e(o,a)}})}onFontFamilyChange(e){this.onFontChange=e}onFontSizeChange(e){this.onSizeChange=e}updateButtonStates(){this.element.querySelectorAll(".toolbar-button").forEach(e=>{const t=e,n=t.dataset.command;n&&this.isCommandActive(n)?t.classList.add("active"):t.classList.remove("active")})}isCommandActive(e){try{return document.queryCommandState(e)}catch{return!1}}setSourceMode(e){this.element.querySelectorAll(".toolbar-button").forEach(t=>{const n=t;"toggleSource"===n.dataset.command&&(e?n.classList.add("active"):n.classList.remove("active"))})}setButtonsDisabled(e){this.element.querySelectorAll(".toolbar-button").forEach(t=>{const n=t;"toggleSource"!==n.dataset.command&&(n.disabled=e)}),this.element.querySelectorAll(".toolbar-select").forEach(t=>{t.disabled=e})}}class a{constructor(e){this.uploadUrl=e}async upload(e){const t=new FormData;t.append("image",e);const n=this.getCsrfToken();try{const e=await fetch(this.uploadUrl,{method:"POST",headers:{"X-CSRFToken":n},body:t,credentials:"same-origin"});if(!e.ok){const t=await e.json();throw new Error(t.error||"Upload failed")}return await e.json()}catch(e){return console.error("Upload error:",e),{success:!1,error:e instanceof Error?e.message:"Upload failed"}}}getCsrfToken(){const e=document.cookie.split(";");for(let t of e)if(t=t.trim(),t.startsWith("csrftoken="))return t.substring(10);return""}static validateFile(e){return["image/jpeg","image/png","image/gif","image/webp"].includes(e.type)?e.size>5242880?{valid:!1,error:"File too large. Maximum size is 5MB."}:{valid:!0}:{valid:!1,error:"Invalid file type. Only JPEG, PNG, GIF, and WebP are allowed."}}}class i{static compress(e){const t=document.createElement("div");t.innerHTML=e;const n={};let o=0;var a;(a=t).querySelectorAll("pre").forEach(e=>{const t=`__PRESERVE_PRE_${o}__`;n[t]=e.innerHTML,e.innerHTML=t,o++}),a.querySelectorAll("code").forEach(e=>{if(e.closest("pre"))return;const t=`__PRESERVE_CODE_${o}__`;n[t]=e.innerHTML,e.innerHTML=t,o++}),this.processNode(t);let i=t.innerHTML;return i=i.replace(/\s+/g," "),i=i.replace(/>\s+</g,"><"),Object.keys(n).forEach(e=>{i=i.replace(e,n[e])}),i.trim()}static processNode(e){const t=[];for(let n=0;n<e.childNodes.length;n++){const o=e.childNodes[n];o.nodeType===Node.ELEMENT_NODE&&t.push(o)}t.forEach(e=>{const t=e.tagName;if(this.PRESERVE_TAGS.includes(t))"PRE"!==t&&"CODE"!==t&&this.processNode(e);else if(this.STYLE_MAP[t]){const n=document.createElement("span"),o=e.getAttribute("style")||"",a=this.STYLE_MAP[t];for(n.setAttribute("style",o?`${o};${a}`:a);e.firstChild;)n.appendChild(e.firstChild);e.parentNode?.replaceChild(n,e),this.processNode(n)}else this.processNode(e)})}static decompress(e){return e}}i.STYLE_MAP={B:"font-weight:bold",STRONG:"font-weight:bold",I:"font-style:italic",EM:"font-style:italic",U:"text-decoration:underline",STRIKE:"text-decoration:line-through",H1:"font-size:2em;font-weight:bold;margin:0.67em 0",H2:"font-size:1.5em;font-weight:bold;margin:0.75em 0",H3:"font-size:1.17em;font-weight:bold;margin:0.83em 0",H4:"font-size:1em;font-weight:bold;margin:1.12em 0",H5:"font-size:0.83em;font-weight:bold;margin:1.5em 0",H6:"font-size:0.67em;font-weight:bold;margin:1.67em 0"},i.PRESERVE_TAGS=["CODE","PRE","IMG","A"];class r{constructor(){this.controlPanel=null,this.currentImage=null,this.isResizing=!1,this.startX=0,this.startWidth=0,this.onChangeCallback=null,this.createControlPanel(),this.attachGlobalListeners()}onChange(e){this.onChangeCallback=e}createControlPanel(){this.controlPanel=document.createElement("div"),this.controlPanel.className="image-control-panel",this.controlPanel.innerHTML='\n <div class="image-control-buttons">\n <button type="button" class="image-control-btn" data-action="align-left" title="Align Left">\n ⬅️\n </button>\n <button type="button" class="image-control-btn" data-action="align-center" title="Align Center">\n ↔️\n </button>\n <button type="button" class="image-control-btn" data-action="align-right" title="Align Right">\n ➡️\n </button>\n <span class="image-control-separator">|</span>\n <button type="button" class="image-control-btn" data-action="size-small" title="Small (25%)">\n S\n </button>\n <button type="button" class="image-control-btn" data-action="size-medium" title="Medium (50%)">\n M\n </button>\n <button type="button" class="image-control-btn" data-action="size-large" title="Large (75%)">\n L\n </button>\n <button type="button" class="image-control-btn" data-action="size-full" title="Full Width (100%)">\n XL\n </button>\n <span class="image-control-separator">|</span>\n <button type="button" class="image-control-btn image-control-delete" data-action="delete" title="Delete Image">\n 🗑️\n </button>\n </div>\n <div class="image-resize-handle" title="Drag to resize"></div>\n ',document.body.appendChild(this.controlPanel),this.controlPanel.addEventListener("click",e=>{const t=e.target.closest(".image-control-btn");t&&(e.preventDefault(),e.stopPropagation(),this.handleAction(t.dataset.action||""))});const e=this.controlPanel.querySelector(".image-resize-handle");e&&e.addEventListener("mousedown",e=>this.startResize(e))}attachGlobalListeners(){document.addEventListener("mousemove",e=>this.onResize(e)),document.addEventListener("mouseup",()=>this.stopResize())}showControls(e){if(this.currentImage=e,!this.controlPanel)return;e.classList.add("image-selected");const t=e.getBoundingClientRect();this.controlPanel.style.display="block",this.controlPanel.style.top=t.top+window.scrollY-45+"px",this.controlPanel.style.left=`${t.left+window.scrollX}px`,this.controlPanel.style.width=`${t.width}px`}hideControls(){this.currentImage&&(this.currentImage.classList.remove("image-selected"),this.currentImage=null),this.controlPanel&&(this.controlPanel.style.display="none")}handleAction(e){if(this.currentImage){switch(e){case"align-left":this.currentImage.style.display="block",this.currentImage.style.marginLeft="0",this.currentImage.style.marginRight="auto";break;case"align-center":this.currentImage.style.display="block",this.currentImage.style.marginLeft="auto",this.currentImage.style.marginRight="auto";break;case"align-right":this.currentImage.style.display="block",this.currentImage.style.marginLeft="auto",this.currentImage.style.marginRight="0";break;case"size-small":this.currentImage.style.width="25%",this.currentImage.style.height="auto";break;case"size-medium":this.currentImage.style.width="50%",this.currentImage.style.height="auto";break;case"size-large":this.currentImage.style.width="75%",this.currentImage.style.height="auto";break;case"size-full":this.currentImage.style.width="100%",this.currentImage.style.height="auto";break;case"delete":return void(confirm("Delete this image?")&&(this.currentImage.remove(),this.hideControls(),this.onChangeCallback&&this.onChangeCallback()))}this.showControls(this.currentImage),this.onChangeCallback&&this.onChangeCallback()}}startResize(e){this.currentImage&&(e.preventDefault(),e.stopPropagation(),this.isResizing=!0,this.startX=e.clientX,this.startWidth=this.currentImage.offsetWidth,document.body.style.cursor="ew-resize",document.body.style.userSelect="none")}onResize(e){if(!this.isResizing||!this.currentImage)return;const t=e.clientX-this.startX,n=this.startWidth+t,o=this.currentImage.parentElement;if(!o)return;const a=n/o.offsetWidth*100,i=Math.max(10,Math.min(100,a));this.currentImage.style.width=`${i}%`,this.currentImage.style.height="auto",this.showControls(this.currentImage)}stopResize(){this.isResizing&&(this.isResizing=!1,document.body.style.cursor="",document.body.style.userSelect="",this.onChangeCallback&&this.onChangeCallback())}isClickInsidePanel(e){return this.controlPanel?.contains(e)||!1}}class s{constructor(e,t,n,i){this.sourceTextarea=null,this.isSourceMode=!1;const s=document.getElementById(e),l=document.getElementById(t);if(!s||!l)throw new Error("Editor or textarea element not found");this.editorElement=s,this.textareaElement=l,this.config=i,this.toolbar=new o(n),this.imageUploader=new a(i.uploadUrl),this.imageControls=new r,this.imageControls.onChange(()=>this.updateTextarea()),this.init()}init(){this.textareaElement.value&&(this.editorElement.innerHTML=this.textareaElement.value),this.config.placeholder&&!this.textareaElement.value&&(this.editorElement.dataset.placeholder=this.config.placeholder),this.config.minHeight&&(this.editorElement.style.minHeight=`${this.config.minHeight}px`),this.config.maxHeight&&(this.editorElement.style.maxHeight=`${this.config.maxHeight}px`),this.setupEventListeners(),this.editorElement.contentEditable="true"}setupEventListeners(){this.toolbar.onAction((e,t)=>{this.executeCommand(e,t)}),this.toolbar.onFontFamilyChange(e=>{this.applyFontFamily(e)}),this.toolbar.onFontSizeChange(e=>{this.applyFontSize(e)}),this.editorElement.addEventListener("mouseup",()=>{this.toolbar.updateButtonStates()}),this.editorElement.addEventListener("keyup",()=>{this.toolbar.updateButtonStates(),this.updateTextarea()}),this.editorElement.addEventListener("input",()=>{this.updateTextarea()}),this.editorElement.addEventListener("paste",e=>{e.preventDefault();const t=e.clipboardData?.getData("text/plain")||"";document.execCommand("insertText",!1,t)}),this.editorElement.addEventListener("paste",async e=>{const t=e.clipboardData?.items;if(t)for(let n=0;n<t.length;n++)if(-1!==t[n].type.indexOf("image")){e.preventDefault();const o=t[n].getAsFile();o&&await this.uploadAndInsertImage(o)}}),this.editorElement.addEventListener("drop",async e=>{e.preventDefault();const t=e.dataTransfer?.files;if(t)for(let e=0;e<t.length;e++)t[e].type.startsWith("image/")&&await this.uploadAndInsertImage(t[e])}),this.editorElement.addEventListener("dragover",e=>{e.preventDefault()}),this.editorElement.addEventListener("keydown",e=>{if(e.ctrlKey||e.metaKey)switch(e.key.toLowerCase()){case"z":e.preventDefault(),e.shiftKey?this.executeCommand("redo"):this.executeCommand("undo");break;case"y":e.preventDefault(),this.executeCommand("redo");break;case"b":e.preventDefault(),this.executeCommand("bold");break;case"i":e.preventDefault(),this.executeCommand("italic");break;case"u":e.preventDefault(),this.executeCommand("underline")}}),this.editorElement.addEventListener("click",e=>{const t=e.target;"IMG"===t.tagName?(e.preventDefault(),this.imageControls.showControls(t)):this.imageControls.isClickInsidePanel(t)||this.imageControls.hideControls()}),document.addEventListener("click",e=>{const t=e.target;this.editorElement.contains(t)||this.imageControls.isClickInsidePanel(t)||this.imageControls.hideControls()})}executeCommand(e,t){"insertImage"!==e?"createLink"!==e?"toggleSource"!==e?"insertCode"!==e?"insertCodeBlock"!==e?"removeFormat"!==e?(this.editorElement.focus(),t?document.execCommand(e,!1,t):document.execCommand(e,!1),this.toolbar.updateButtonStates(),this.updateTextarea()):this.clearFormatting():this.insertCodeBlock():this.insertInlineCode():this.toggleSourceMode():this.promptCreateLink():this.promptImageUpload()}promptImageUpload(){const e=document.createElement("input");e.type="file",e.accept="image/*",e.onchange=async e=>{const t=e.target.files?.[0];t&&await this.uploadAndInsertImage(t)},e.click()}async uploadAndInsertImage(e){const t=a.validateFile(e);if(!t.valid)return void alert(t.error);const n=document.createElement("span");n.textContent="⏳ Uploading...",n.style.color="#999",this.insertNodeAtCursor(n);const o=await this.imageUploader.upload(e);if(n.remove(),o.success&&o.url){const t=document.createElement("img");t.src=o.url,t.alt=e.name,t.style.maxWidth="100%",t.dataset.imageId=o.id?.toString()||"",this.insertNodeAtCursor(t),this.updateTextarea()}else alert(`Upload failed: ${o.error}`)}promptCreateLink(){const e=prompt("Enter URL:");e&&(document.execCommand("createLink",!1,e),this.updateTextarea())}insertInlineCode(){const e=window.getSelection();if(e){if(this.editorElement.focus(),!e.isCollapsed&&e.rangeCount>0){const t=e.getRangeAt(0);let n=t.commonAncestorContainer,o=null;for(;n&&n!==this.editorElement;){if("CODE"===n.nodeName){o=n;break}n=n.parentNode}if(o){const t=o.parentNode;if(t){const n=document.createTextNode(o.textContent||"");t.replaceChild(n,o);const a=document.createRange();a.selectNodeContents(n),e.removeAllRanges(),e.addRange(a)}}else{const n=document.createElement("code");try{t.surroundContents(n)}catch(e){const o=t.toString();n.textContent=o,t.deleteContents(),t.insertNode(n)}t.setStartAfter(n),t.setEndAfter(n),e.removeAllRanges(),e.addRange(t)}}else{let t=e.anchorNode,n=null;for(;t&&t!==this.editorElement;){if("CODE"===t.nodeName){n=t;break}t=t.parentNode}if(n){const t=n.parentNode;if(t){const o=document.createTextNode(n.textContent||"");t.replaceChild(o,n);const a=document.createRange();a.setStart(o,0),a.setEnd(o,0),e.removeAllRanges(),e.addRange(a)}}else{const t=document.createElement("code");t.textContent="code",this.insertNodeAtCursor(t);const n=document.createRange();n.selectNodeContents(t),e.removeAllRanges(),e.addRange(n)}}this.updateTextarea()}}insertCodeBlock(){const e=window.getSelection();if(!e)return;this.editorElement.focus();const t=document.createElement("pre"),n=document.createElement("code");if(!e.isCollapsed&&e.rangeCount>0){const t=e.getRangeAt(0),o=t.toString();n.textContent=o,t.deleteContents()}else n.textContent="// Enter your code here";t.appendChild(n),this.insertNodeAtCursor(t);const o=document.createElement("br");this.insertNodeAtCursor(o);const a=document.createRange();a.selectNodeContents(n),e.removeAllRanges(),e.addRange(a),this.updateTextarea()}clearFormatting(){this.editorElement.focus();const e=window.getSelection();if(!e||0===e.rangeCount)return;const t=e.getRangeAt(0);if(t.collapsed)return;const n=t.toString(),o=document.createTextNode(n);t.deleteContents(),t.insertNode(o),t.setStartBefore(o),t.setEndAfter(o),e.removeAllRanges(),e.addRange(t),this.toolbar.updateButtonStates(),this.updateTextarea()}insertNodeAtCursor(e){const t=window.getSelection();if(!t||0===t.rangeCount)return void this.editorElement.appendChild(e);const n=t.getRangeAt(0);n.deleteContents(),n.insertNode(e),n.setStartAfter(e),n.setEndAfter(e),t.removeAllRanges(),t.addRange(n)}updateTextarea(){const e=this.editorElement.innerHTML,t=i.compress(e);this.textareaElement.value=t}getContent(){return this.textareaElement.value}setContent(e){this.editorElement.innerHTML=e,this.updateTextarea()}clear(){this.editorElement.innerHTML="",this.updateTextarea()}toggleSourceMode(){this.isSourceMode=!this.isSourceMode,this.isSourceMode?this.showSourceMode():this.hideSourceMode(),this.toolbar.setSourceMode(this.isSourceMode),this.toolbar.setButtonsDisabled(this.isSourceMode)}showSourceMode(){this.sourceTextarea||(this.sourceTextarea=document.createElement("textarea"),this.sourceTextarea.className="visual-editor-source",this.config.minHeight&&(this.sourceTextarea.style.minHeight=`${this.config.minHeight}px`),this.config.maxHeight&&(this.sourceTextarea.style.maxHeight=`${this.config.maxHeight}px`),this.editorElement.parentNode?.insertBefore(this.sourceTextarea,this.editorElement.nextSibling));const e=this.editorElement.innerHTML;this.sourceTextarea.value=this.formatHtml(e),this.editorElement.style.display="none",this.sourceTextarea.style.display="block",this.sourceTextarea.focus()}hideSourceMode(){if(!this.sourceTextarea)return;const e=this.sourceTextarea.value;this.editorElement.innerHTML=e,this.sourceTextarea.style.display="none",this.editorElement.style.display="block",this.textareaElement.value=e,this.editorElement.focus()}formatHtml(e){let t=e;return t=t.replace(/(<\/[^>]+>)/g,"$1\n"),t=t.replace(/(<(?!\/)(img|br|input|span|a|b|i|u|strong|em)[^>]*>)/gi,"$1"),t=t.replace(/(<(?!\/)(?!img|br|input|span|a|b|i|u|strong|em)[^>]+>)/gi,"\n$1"),t=t.replace(/\n\s*\n/g,"\n"),t=t.trim(),t}applyFontFamily(e){if(this.editorElement.focus(),e)document.execCommand("fontName",!1,e);else{const e=window.getSelection();if(e&&e.rangeCount>0){const t=e.getRangeAt(0),n=document.createElement("span");n.style.fontFamily="",t.surroundContents(n)}}this.updateTextarea()}applyFontSize(e){this.editorElement.focus(),document.execCommand("fontSize",!1,e),this.updateTextarea()}}var l=n(72),c=n.n(l),d=n(825),m=n.n(d),u=n(659),h=n.n(u),p=n(56),g=n.n(p),f=n(540),b=n.n(f),v=n(113),x=n.n(v),y=n(293),C={};function E(){document.querySelectorAll(".visual-editor-container").forEach(e=>{const t=e.getAttribute("data-editor-config");if(!t)return;const n=JSON.parse(t),o=e.querySelector(".visual-editor-content"),a=e.querySelector("textarea"),i=e.querySelector(".visual-editor-toolbar");if(o&&a&&i){o.id||(o.id=`editor-${Math.random().toString(36).substr(2,9)}`),a.id||(a.id=`textarea-${Math.random().toString(36).substr(2,9)}`),i.id||(i.id=`toolbar-${Math.random().toString(36).substr(2,9)}`);try{new s(o.id,a.id,i.id,n)}catch(e){console.error("Failed to initialize editor:",e)}}else console.error("Missing required editor elements")})}C.styleTagTransform=x(),C.setAttributes=g(),C.insert=h().bind(null,"head"),C.domAPI=m(),C.insertStyleElement=b(),c()(y.A,C),y.A&&y.A.locals&&y.A.locals,"loading"===document.readyState?document.addEventListener("DOMContentLoaded",E):E()})();
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{% load static %}
|
|
2
|
+
<div class="visual-editor-container" data-editor-config="{{ widget.editor_config }}">
|
|
3
|
+
<div class="visual-editor-toolbar" id="toolbar-{{ widget.attrs.id }}"></div>
|
|
4
|
+
<div class="visual-editor-content" id="editor-{{ widget.attrs.id }}" contenteditable="true"></div>
|
|
5
|
+
<textarea {{ widget.attrs|safe }} name="{{ widget.name }}" style="display: none;">{{ widget.value|default:"" }}</textarea>
|
|
6
|
+
</div>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from django.http import JsonResponse
|
|
2
|
+
from django.views.decorators.http import require_http_methods
|
|
3
|
+
from django.views.decorators.csrf import csrf_exempt
|
|
4
|
+
from django.contrib.auth.decorators import login_required
|
|
5
|
+
from .models import EditorImage
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@require_http_methods(["POST"])
|
|
10
|
+
@login_required
|
|
11
|
+
def upload_image(request):
|
|
12
|
+
"""
|
|
13
|
+
Handle image upload from the editor
|
|
14
|
+
"""
|
|
15
|
+
try:
|
|
16
|
+
if "image" not in request.FILES:
|
|
17
|
+
return JsonResponse({"error": "No image provided"}, status=400)
|
|
18
|
+
|
|
19
|
+
image_file = request.FILES["image"]
|
|
20
|
+
|
|
21
|
+
# Validate file type
|
|
22
|
+
allowed_types = ["image/jpeg", "image/png", "image/gif", "image/webp"]
|
|
23
|
+
if image_file.content_type not in allowed_types:
|
|
24
|
+
return JsonResponse({"error": "Invalid file type"}, status=400)
|
|
25
|
+
|
|
26
|
+
# Validate file size (max 5MB)
|
|
27
|
+
max_size = 5 * 1024 * 1024
|
|
28
|
+
if image_file.size > max_size:
|
|
29
|
+
return JsonResponse(
|
|
30
|
+
{"error": "File too large. Max size is 5MB"}, status=400
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Create image instance
|
|
34
|
+
editor_image = EditorImage(
|
|
35
|
+
image=image_file,
|
|
36
|
+
uploaded_by=request.user if request.user.is_authenticated else None,
|
|
37
|
+
)
|
|
38
|
+
editor_image.save()
|
|
39
|
+
|
|
40
|
+
return JsonResponse(
|
|
41
|
+
{"success": True, "url": editor_image.image.url, "id": editor_image.id}
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
except Exception as e:
|
|
45
|
+
return JsonResponse({"error": str(e)}, status=500)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from django import forms
|
|
2
|
+
from django.conf import settings
|
|
3
|
+
from django.urls import reverse
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class VisualEditorWidget(forms.Textarea):
|
|
8
|
+
"""
|
|
9
|
+
Widget for the Visual Editor
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
template_name = "django_visual_editor/widget.html"
|
|
13
|
+
|
|
14
|
+
class Media:
|
|
15
|
+
css = {"all": ("django_visual_editor/css/editor.css",)}
|
|
16
|
+
js = ("django_visual_editor/js/editor.bundle.js",)
|
|
17
|
+
|
|
18
|
+
def __init__(self, attrs=None, config=None):
|
|
19
|
+
default_attrs = {"class": "visual-editor-textarea"}
|
|
20
|
+
if attrs:
|
|
21
|
+
default_attrs.update(attrs)
|
|
22
|
+
super().__init__(default_attrs)
|
|
23
|
+
|
|
24
|
+
self.config = config or {}
|
|
25
|
+
|
|
26
|
+
def get_context(self, name, value, attrs):
|
|
27
|
+
context = super().get_context(name, value, attrs)
|
|
28
|
+
|
|
29
|
+
# Add editor configuration
|
|
30
|
+
editor_config = {
|
|
31
|
+
"uploadUrl": reverse("django_visual_editor:upload_image"),
|
|
32
|
+
"minHeight": self.config.get("min_height", 300),
|
|
33
|
+
"maxHeight": self.config.get("max_height", 600),
|
|
34
|
+
"placeholder": self.config.get("placeholder", "Start typing..."),
|
|
35
|
+
**self.config,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
context["widget"]["editor_config"] = json.dumps(editor_config)
|
|
39
|
+
return context
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-visual-editor
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Visual text editor for Django with image upload, formatting and HTML compression
|
|
5
|
+
Author-email: Vladislav Khoboko <vladislah@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/hvlads/django-visual-editor
|
|
8
|
+
Project-URL: Repository, https://github.com/hvlads/django-visual-editor
|
|
9
|
+
Project-URL: Issues, https://github.com/hvlads/django-visual-editor/issues
|
|
10
|
+
Keywords: django,editor,wysiwyg,visual-editor,rich-text,html-editor
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Web Environment
|
|
13
|
+
Classifier: Framework :: Django
|
|
14
|
+
Classifier: Framework :: Django :: 5.2
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
25
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
|
|
26
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
27
|
+
Requires-Python: >=3.9
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
Requires-Dist: django>=4.2
|
|
31
|
+
Requires-Dist: pillow>=10.0.0
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
# Django Visual Editor
|
|
35
|
+
|
|
36
|
+
A visual text editor for Django with image upload support, text formatting, and HTML compression.
|
|
37
|
+
|
|
38
|
+
## Features
|
|
39
|
+
|
|
40
|
+
- **Visual Editing**: Intuitive WYSIWYG editor
|
|
41
|
+
- **Text Formatting**:
|
|
42
|
+
- Bold, Italic, Underline, Strikethrough
|
|
43
|
+
- Font family selection (Arial, Times New Roman, Georgia, Courier New, etc.)
|
|
44
|
+
- Font size selection (10px - 24px)
|
|
45
|
+
- Clear formatting
|
|
46
|
+
- **Headings**: H1, H2, H3
|
|
47
|
+
- **Lists**: Numbered and bulleted lists
|
|
48
|
+
- **Code**:
|
|
49
|
+
- Inline code (`<code>` tag) with toggle support
|
|
50
|
+
- Code blocks (pre+code) for multi-line code
|
|
51
|
+
- **Images**: Upload via drag-and-drop, paste, or file picker
|
|
52
|
+
- Resize images by dragging or using preset sizes (S, M, L, XL)
|
|
53
|
+
- Align images (left, center, right)
|
|
54
|
+
- Delete images
|
|
55
|
+
- **Links**: Create hyperlinks
|
|
56
|
+
- **Undo/Redo**: Full history support (Ctrl+Z, Ctrl+Y)
|
|
57
|
+
- **HTML Source Mode**: Toggle between visual and HTML code editing
|
|
58
|
+
- **HTML Compression**: Automatic conversion to compact HTML with inline styles
|
|
59
|
+
- **Keyboard Shortcuts**: Ctrl+B (Bold), Ctrl+I (Italic), Ctrl+U (Underline), Ctrl+Z (Undo), Ctrl+Y (Redo)
|
|
60
|
+
- **Auto Cleanup**: Command to remove unused images
|
|
61
|
+
|
|
62
|
+
## Installation
|
|
63
|
+
|
|
64
|
+
### 1. Install Dependencies
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
# Install Python dependencies
|
|
68
|
+
pip install django Pillow
|
|
69
|
+
|
|
70
|
+
# Install Node.js dependencies for frontend build
|
|
71
|
+
cd frontend
|
|
72
|
+
npm install
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### 2. Build Frontend
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
cd frontend
|
|
79
|
+
npm run build
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
For development with automatic rebuild:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
npm run dev
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### 3. Configure Django
|
|
89
|
+
|
|
90
|
+
Add to `settings.py`:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
INSTALLED_APPS = [
|
|
94
|
+
...
|
|
95
|
+
'django_visual_editor',
|
|
96
|
+
...
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
# Media files settings
|
|
100
|
+
MEDIA_URL = 'media/'
|
|
101
|
+
MEDIA_ROOT = BASE_DIR / 'media'
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Add URL to `urls.py`:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from django.conf import settings
|
|
108
|
+
from django.conf.urls.static import static
|
|
109
|
+
|
|
110
|
+
urlpatterns = [
|
|
111
|
+
...
|
|
112
|
+
path('editor/', include('django_visual_editor.urls')),
|
|
113
|
+
...
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
if settings.DEBUG:
|
|
117
|
+
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### 4. Run Migrations
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
python manage.py migrate
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Usage
|
|
127
|
+
|
|
128
|
+
### Option 1: Using VisualEditorField (Recommended)
|
|
129
|
+
|
|
130
|
+
The simplest way - just use the field in your model:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
from django.db import models
|
|
134
|
+
from django_visual_editor import VisualEditorField
|
|
135
|
+
|
|
136
|
+
class BlogPost(models.Model):
|
|
137
|
+
title = models.CharField(max_length=200)
|
|
138
|
+
content = VisualEditorField(
|
|
139
|
+
config={
|
|
140
|
+
'min_height': 400,
|
|
141
|
+
'max_height': 800,
|
|
142
|
+
'placeholder': 'Start typing...',
|
|
143
|
+
}
|
|
144
|
+
)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Then use it in forms and admin - no additional configuration needed:
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
# forms.py
|
|
151
|
+
from django import forms
|
|
152
|
+
from .models import BlogPost
|
|
153
|
+
|
|
154
|
+
class BlogPostForm(forms.ModelForm):
|
|
155
|
+
class Meta:
|
|
156
|
+
model = BlogPost
|
|
157
|
+
fields = ['title', 'content']
|
|
158
|
+
# Widget is automatically set from the field!
|
|
159
|
+
|
|
160
|
+
# admin.py
|
|
161
|
+
from django.contrib import admin
|
|
162
|
+
from .models import BlogPost
|
|
163
|
+
|
|
164
|
+
@admin.register(BlogPost)
|
|
165
|
+
class BlogPostAdmin(admin.ModelAdmin):
|
|
166
|
+
pass # Widget is automatically set from the field!
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### Option 2: Using VisualEditorWidget Manually
|
|
170
|
+
|
|
171
|
+
If you prefer to use a regular TextField and configure the widget in forms:
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
# models.py
|
|
175
|
+
from django.db import models
|
|
176
|
+
|
|
177
|
+
class BlogPost(models.Model):
|
|
178
|
+
title = models.CharField(max_length=200)
|
|
179
|
+
content = models.TextField() # Regular TextField
|
|
180
|
+
|
|
181
|
+
# forms.py
|
|
182
|
+
from django import forms
|
|
183
|
+
from django_visual_editor import VisualEditorWidget
|
|
184
|
+
from .models import BlogPost
|
|
185
|
+
|
|
186
|
+
class BlogPostForm(forms.ModelForm):
|
|
187
|
+
class Meta:
|
|
188
|
+
model = BlogPost
|
|
189
|
+
fields = ['title', 'content']
|
|
190
|
+
widgets = {
|
|
191
|
+
'content': VisualEditorWidget(
|
|
192
|
+
config={
|
|
193
|
+
'min_height': 400,
|
|
194
|
+
'max_height': 800,
|
|
195
|
+
'placeholder': 'Start typing...',
|
|
196
|
+
}
|
|
197
|
+
),
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
# admin.py
|
|
201
|
+
from django.contrib import admin
|
|
202
|
+
from django_visual_editor import VisualEditorWidget
|
|
203
|
+
from .models import BlogPost
|
|
204
|
+
from django import forms
|
|
205
|
+
|
|
206
|
+
class BlogPostAdminForm(forms.ModelForm):
|
|
207
|
+
class Meta:
|
|
208
|
+
model = BlogPost
|
|
209
|
+
fields = '__all__'
|
|
210
|
+
widgets = {
|
|
211
|
+
'content': VisualEditorWidget(),
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
@admin.register(BlogPost)
|
|
215
|
+
class BlogPostAdmin(admin.ModelAdmin):
|
|
216
|
+
form = BlogPostAdminForm
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
### In Templates
|
|
220
|
+
|
|
221
|
+
```django
|
|
222
|
+
<!-- Display content -->
|
|
223
|
+
<div class="blog-content">
|
|
224
|
+
{{ post.content|safe }}
|
|
225
|
+
</div>
|
|
226
|
+
|
|
227
|
+
<!-- Form -->
|
|
228
|
+
<form method="post">
|
|
229
|
+
{% csrf_token %}
|
|
230
|
+
{{ form.as_p }}
|
|
231
|
+
{{ form.media }} <!-- Important! Loads CSS and JS -->
|
|
232
|
+
<button type="submit">Save</button>
|
|
233
|
+
</form>
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
## Configuration
|
|
237
|
+
|
|
238
|
+
Available configuration parameters for `VisualEditorWidget`:
|
|
239
|
+
|
|
240
|
+
```python
|
|
241
|
+
VisualEditorWidget(
|
|
242
|
+
config={
|
|
243
|
+
'min_height': 300, # Minimum editor height (px)
|
|
244
|
+
'max_height': 600, # Maximum editor height (px)
|
|
245
|
+
'placeholder': 'Text...', # Placeholder text
|
|
246
|
+
}
|
|
247
|
+
)
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
## Cleanup Unused Images
|
|
251
|
+
|
|
252
|
+
Run the management command to remove unused images:
|
|
253
|
+
|
|
254
|
+
```bash
|
|
255
|
+
# Show what will be deleted (dry run)
|
|
256
|
+
python manage.py cleanup_editor_images --dry-run
|
|
257
|
+
|
|
258
|
+
# Delete unused images
|
|
259
|
+
python manage.py cleanup_editor_images
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
It's recommended to set up this command in cron for periodic cleanup.
|
|
263
|
+
|
|
264
|
+
## Example Project
|
|
265
|
+
|
|
266
|
+
Run the example blog:
|
|
267
|
+
|
|
268
|
+
```bash
|
|
269
|
+
cd example_project
|
|
270
|
+
python manage.py migrate
|
|
271
|
+
python manage.py createsuperuser
|
|
272
|
+
python manage.py runserver
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
Then open:
|
|
276
|
+
- http://localhost:8000/ - Post list
|
|
277
|
+
- http://localhost:8000/post/new/ - Create new post
|
|
278
|
+
- http://localhost:8000/admin/ - Django Admin
|
|
279
|
+
|
|
280
|
+
## Project Structure
|
|
281
|
+
|
|
282
|
+
```
|
|
283
|
+
django-visual-editor/
|
|
284
|
+
├── django_visual_editor/ # Django application
|
|
285
|
+
│ ├── models.py # Model for uploaded images
|
|
286
|
+
│ ├── widgets.py # Django widget
|
|
287
|
+
│ ├── views.py # View for image upload
|
|
288
|
+
│ ├── urls.py # URL configuration
|
|
289
|
+
│ ├── management/ # Management commands
|
|
290
|
+
│ ├── static/ # Static files (compiled)
|
|
291
|
+
│ └── templates/ # Templates
|
|
292
|
+
├── frontend/ # TypeScript sources
|
|
293
|
+
│ ├── src/
|
|
294
|
+
│ │ ├── editor/ # Main editor
|
|
295
|
+
│ │ ├── utils/ # Utils (upload, compression)
|
|
296
|
+
│ │ ├── types/ # TypeScript types
|
|
297
|
+
│ │ └── styles/ # CSS styles
|
|
298
|
+
│ ├── package.json
|
|
299
|
+
│ ├── tsconfig.json
|
|
300
|
+
│ └── webpack.config.js
|
|
301
|
+
└── example_project/ # Usage example
|
|
302
|
+
└── blog/ # Demo blog application
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
## Technologies
|
|
306
|
+
|
|
307
|
+
- **Backend**: Django 5.2+
|
|
308
|
+
- **Frontend**: TypeScript, Webpack
|
|
309
|
+
- **Editor**: Custom implementation using ContentEditable API
|
|
310
|
+
- **Styles**: Vanilla CSS
|
|
311
|
+
|
|
312
|
+
## License
|
|
313
|
+
|
|
314
|
+
MIT
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
django_visual_editor/__init__.py,sha256=Z3DoijvoekxdTi68Nw_u08nmb8jJpNCqIEKfE4TbOgg,349
|
|
2
|
+
django_visual_editor/apps.py,sha256=rmfkQ1LxKCI4d9YMMLeCukXebj79AE8lNEA19zlLHkQ,212
|
|
3
|
+
django_visual_editor/fields.py,sha256=K6giuWkzYOrEc10LV_vHFKJgG29lSpdMPLue3KfXlbo,1262
|
|
4
|
+
django_visual_editor/models.py,sha256=FUc1MGa3GRRSAvmv3c4ayN5i7JBkeESjqp3tmexMpQI,931
|
|
5
|
+
django_visual_editor/urls.py,sha256=2qld5dBvsc02erjMhykdpBfhIjTkmNOB9K6v1c3Mu0E,165
|
|
6
|
+
django_visual_editor/views.py,sha256=vS7h5ycz2MiNWOSAAHXJXsukBFzU0cwPk6l9JZFVgyU,1463
|
|
7
|
+
django_visual_editor/widgets.py,sha256=MDOgK9XhYRVXcWWwyXjSBl_M91ZaCKMVRZYkv9SJVbE,1207
|
|
8
|
+
django_visual_editor/migrations/0001_initial.py,sha256=pgGjRq-4ibB8JhR2Lc4nqKw30Qjg3perjCLH_-Q7jk8,1642
|
|
9
|
+
django_visual_editor/migrations/__init__.py,sha256=nUCd4RwdXl7Ik6vGXxcLjzw2WXjTV-mX5eYvZIud-gU,22
|
|
10
|
+
django_visual_editor/static/django_visual_editor/css/editor.css,sha256=STdOhxyc6fEbbfNCFCJJEb5tlfZoiAf5H8Bl_uoSn5I,150
|
|
11
|
+
django_visual_editor/static/django_visual_editor/js/editor.bundle.js,sha256=jD_w4BuxXOx1nUWpeuGD1uM8WTqvl6KoA-FKNFzZ4l4,31893
|
|
12
|
+
django_visual_editor/templates/django_visual_editor/widget.html,sha256=r6GY9qy-wyqlNyHIsFY3pchWio4XA5kkicylqmz_hKQ,421
|
|
13
|
+
django_visual_editor-0.1.1.dist-info/licenses/LICENSE,sha256=ayFs2d6hRpHfXDpnnJdH3WXKakkSoOBmGSGtO2-bgq4,1073
|
|
14
|
+
django_visual_editor-0.1.1.dist-info/METADATA,sha256=gVGvu5WWOPDah4bvu_jeX4FFnbUaKLyiIuEjMI1shBI,8176
|
|
15
|
+
django_visual_editor-0.1.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
16
|
+
django_visual_editor-0.1.1.dist-info/top_level.txt,sha256=2TOKCfKSniY4wIhBl_8CIWkgKzOpniuB5gk45yg9Y2g,21
|
|
17
|
+
django_visual_editor-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Vladislav Khoboko
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
django_visual_editor
|