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.
@@ -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,7 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class DjangoVisualEditorConfig(AppConfig):
5
+ default_auto_field = "django.db.models.BigAutoField"
6
+ name = "django_visual_editor"
7
+ verbose_name = "Django Visual Editor"
@@ -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,2 @@
1
+ /* This file will be replaced by the compiled CSS from webpack */
2
+ /* Run 'npm run build' in the frontend directory to generate the compiled assets */
@@ -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:"&lt;code&gt;",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:"&lt;/&gt;",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,8 @@
1
+ from django.urls import path
2
+ from . import views
3
+
4
+ app_name = "django_visual_editor"
5
+
6
+ urlpatterns = [
7
+ path("upload/", views.upload_image, name="upload_image"),
8
+ ]
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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