")
45 |
46 | return NestedListElement(element, s, attrs={"class": "nested-list", "style": f"--depth: {depth}"})
47 |
48 | class NestedListFeature(EditorJSFeature):
49 | allowed_tags = ["ul", "ol", "li"]
50 | allowed_attributes = ["class", "style"]
51 | klass="NestedList"
52 | js = [
53 | "wagtail_editorjs/vendor/editorjs/tools/nested-list.js",
54 | ]
55 |
56 | def validate(self, data: Any):
57 | super().validate(data)
58 |
59 | items = data["data"].get("items")
60 | if not items:
61 | raise forms.ValidationError("Invalid items value")
62 |
63 | if "style" not in data["data"]:
64 | raise forms.ValidationError("Invalid style value")
65 |
66 | if data["data"]["style"] not in ["ordered", "unordered"]:
67 | raise forms.ValidationError("Invalid style value")
68 |
69 | def render_block_data(self, block: EditorJSBlock, context = None) -> EditorJSElement:
70 | element = "ol" if block["data"]["style"] == "ordered" else "ul"
71 | return parse_list(block["data"]["items"], element)
72 |
73 | @classmethod
74 | def get_test_data(cls):
75 | return [
76 | {
77 | "style": "unordered",
78 | "items": [
79 | {
80 | "content": "Item 1",
81 | "items": [
82 | {
83 | "content": "Item 1.1",
84 | "items": [
85 | {
86 | "content": "Item 1.1.1",
87 | "items": [],
88 | },
89 | {
90 | "content": "Item 1.1.2",
91 | "items": [],
92 | },
93 | ],
94 | },
95 | {
96 | "content": "Item 1.2",
97 | "items": [],
98 | },
99 | ],
100 | },
101 | {
102 | "content": "Item 2",
103 | "items": [],
104 | },
105 | ],
106 | },
107 | ]
108 |
109 |
110 | class CheckListFeature(EditorJSFeature):
111 | allowed_tags = ["ul", "li"]
112 | allowed_attributes = ["class"]
113 | klass="Checklist"
114 | js=[
115 | "wagtail_editorjs/vendor/editorjs/tools/checklist.js",
116 | ]
117 |
118 | def validate(self, data: Any):
119 | super().validate(data)
120 |
121 | items = data["data"].get("items")
122 | if not items:
123 | raise forms.ValidationError("Invalid items value")
124 |
125 | for item in items:
126 | if "checked" not in item:
127 | raise forms.ValidationError("Invalid checked value")
128 |
129 | if "text" not in item:
130 | raise forms.ValidationError("Invalid text value")
131 |
132 | def render_block_data(self, block: EditorJSBlock, context = None) -> EditorJSElement:
133 | s = []
134 | for item in block["data"]["items"]:
135 | class_ = "checklist-item"
136 | if item["checked"]:
137 | class_ += " checked"
138 |
139 | s.append(wrap_tag("li", {"class": class_}, item["text"]))
140 |
141 | return EditorJSElement("ul", "".join(s), attrs={"class": "checklist"})
142 |
143 | @classmethod
144 | def get_test_data(cls):
145 | return [
146 | {
147 | "items": [
148 | {
149 | "checked": True,
150 | "text": "Item 1",
151 | },
152 | {
153 | "checked": False,
154 | "text": "Item 2",
155 | },
156 | ],
157 | }
158 | ]
159 |
160 |
--------------------------------------------------------------------------------
/wagtail_editorjs/render.py:
--------------------------------------------------------------------------------
1 | from typing import Any, Union
2 | from collections import defaultdict
3 | from django.template.loader import render_to_string
4 | from django.template.context import Context
5 | from django.utils.safestring import mark_safe
6 | from . import settings
7 | from .registry import (
8 | EditorJSElement,
9 | InlineEditorJSFeature,
10 | EDITOR_JS_FEATURES,
11 | )
12 | import bleach, bs4
13 |
14 |
15 | class NullSanitizer:
16 | @staticmethod
17 | def sanitize_css(val):
18 | return val
19 |
20 | def render_editorjs_html(
21 | features: list[str],
22 | data: dict,
23 | context=None,
24 | clean: bool = None,
25 | whitelist_tags: list[str] = None,
26 | whitelist_attrs: Union[dict, list] = None
27 | ) -> str:
28 | """
29 | Renders the editorjs widget based on the features provided.
30 | """
31 |
32 | if "blocks" not in data:
33 | data["blocks"] = []
34 |
35 | feature_mappings = {
36 | feature: EDITOR_JS_FEATURES[feature]
37 | for feature in features
38 | }
39 |
40 | inlines = [
41 | feature
42 | for feature in feature_mappings.values()
43 | if isinstance(feature, InlineEditorJSFeature)
44 | ]
45 |
46 | html = []
47 | for block in data["blocks"]:
48 |
49 | feature: str = block["type"]
50 | tunes: dict[str, Any] = block.get("tunes", {})
51 | feature_mapping = feature_mappings.get(feature, None)
52 |
53 | if not feature_mapping:
54 | continue
55 |
56 | # Build the actual block.
57 | element: EditorJSElement = feature_mapping.render_block_data(block, context)
58 |
59 | # Optionally tools can decide to not render the block.
60 | if element is None:
61 | continue
62 |
63 | # Tune the element.
64 | for tune_name, tune_value in tunes.items():
65 | if tune_name not in feature_mappings:
66 | continue
67 |
68 | element = feature_mappings[tune_name].tune_element(element, tune_value, context)
69 |
70 | # Add the block ID to each individual block.
71 | if settings.ADD_BLOCK_ID:
72 | # This can be used to link frontend to the admin area.
73 | element.attrs[settings.BLOCK_ID_ATTR] = block.get("id", "")
74 |
75 | html.append(element)
76 |
77 | html = "\n".join([str(h) for h in html])
78 |
79 | soup = bs4.BeautifulSoup(html, "html.parser")
80 | if inlines:
81 | for inline in inlines:
82 | # Give inlines access to whole soup.
83 | # This allows for proper parsing of say; page or document links.
84 | inline: InlineEditorJSFeature
85 | inline.parse_inline_data(soup, context)
86 |
87 | # Re-render the soup.
88 | html = soup.decode(False)
89 |
90 | if clean or (clean is None and settings.CLEAN_HTML):
91 | allowed_tags = set({
92 | # Default inline tags.
93 | "i", "b", "strong", "em", "u", "s", "strike"
94 | })
95 | allowed_attributes = defaultdict(set)
96 | # cleaner_funcs = defaultdict(lambda: defaultdict(list))
97 |
98 | for feature in feature_mappings.values():
99 | allowed_tags.update(feature.allowed_tags)
100 | # for key, value in feature.cleaner_funcs.items():
101 | # for name, func in value.items():
102 | # cleaner_funcs[key][name].append(func)
103 |
104 | for key, value in feature.allowed_attributes.items():
105 | allowed_attributes[key].update(value)
106 |
107 | if whitelist_tags:
108 | allowed_tags.update(whitelist_tags)
109 |
110 | if "*" in allowed_attributes:
111 | allowed_attributes["*"].add(settings.BLOCK_ID_ATTR)
112 | else:
113 | allowed_attributes["*"] = {settings.BLOCK_ID_ATTR}
114 |
115 | if whitelist_attrs:
116 | if isinstance(whitelist_attrs, dict):
117 | for key, value in whitelist_attrs.items():
118 | allowed_attributes[key].update(value)
119 | else:
120 | for key in allowed_attributes:
121 | allowed_attributes[key].update(whitelist_attrs)
122 |
123 | html = bleach.clean(
124 | html,
125 | tags=allowed_tags,
126 | attributes=allowed_attributes,
127 | css_sanitizer=NullSanitizer,
128 | )
129 |
130 | ctx = context or {}
131 | ctx["html"] = html
132 |
133 | if isinstance(context, Context):
134 | ctx = context.flatten()
135 |
136 | return render_to_string(
137 | "wagtail_editorjs/rich_text.html",
138 | context=ctx,
139 | request=ctx.get("request", None)
140 | )
141 |
142 |
143 |
144 | # def parse_allowed_attributes(tag, name, value):
145 | # if (
146 | # tag not in allowed_attributes\
147 | # and tag not in cleaner_funcs\
148 | # and "*" not in cleaner_funcs\
149 | # and "*" not in allowed_attributes
150 | # ):
151 | # return False
152 | #
153 | # if "*" in cleaner_funcs and name in cleaner_funcs["*"] and any(
154 | # func(value) for func in cleaner_funcs["*"][name]
155 | # ):
156 | # return True
157 | #
158 | # if tag in cleaner_funcs\
159 | # and name in cleaner_funcs[tag]\
160 | # and any(
161 | # func(value) for func in cleaner_funcs[tag][name]
162 | # ):
163 | # return True
164 | #
165 | # if name in allowed_attributes[tag] or name in allowed_attributes["*"]:
166 | # return True
167 | #
168 | # return False
169 |
--------------------------------------------------------------------------------
/wagtail_editorjs/static/wagtail_editorjs/js/tools/wagtail-button-tool.js:
--------------------------------------------------------------------------------
1 | const wagtailButtonIcon = ``;
7 |
8 | const wagtailButtonEditIcon = ``;
14 |
15 |
16 | class PageButtonTool extends window.BaseWagtailEditorJSTool {
17 | constructor({ data, api, config, block }) {
18 | super({ data, api, config, block });
19 |
20 | this.settings = [
21 | new window.BaseButtonSetting({
22 | icon: wagtailButtonIcon,
23 | name: 'change-url',
24 | description: this.api.i18n.t('Change URL'),
25 | action: () => {
26 | window.openChooserModal(
27 | this.pageChooser, this.setData.bind(this),
28 | )
29 | },
30 | }),
31 | ];
32 | this.initSettings();
33 | this.pageChooser = this.newChooser();
34 | }
35 |
36 | setData(data) {
37 | this.wrapperElement.dataset.url = data.url;
38 | this.wrapperElement.dataset.pageId = data.id;
39 | this.wrapperElement.dataset.parentPageId = data.parentId;
40 | this.buttonLinkElement.href = data.url;
41 | this.buttonElement.innerText = data.title;
42 | }
43 |
44 |
45 | newChooser() {
46 | let urlParams = {
47 | page_type: this.config.page_type || 'wagtailcore.page',
48 | allow_external_link: this.config.allow_external_link || true,
49 | allow_email_link: this.config.allow_email_link || true,
50 | allow_phone_link: this.config.allow_phone_link || true,
51 | allow_anchor_link: this.config.allow_anchor_link || true,
52 | };
53 |
54 | const cfg = {
55 | url: this.config.chooserUrls.pageChooser,
56 | urlParams: urlParams,
57 | onload: window.PAGE_CHOOSER_MODAL_ONLOAD_HANDLERS,
58 | modelNames: ['wagtailcore.page'],
59 | };
60 |
61 | return new window.PageChooser(this.config.chooserId, cfg);
62 | }
63 |
64 | static get toolbox() {
65 | return {
66 | title: 'Page Button',
67 | icon: wagtailButtonIcon,
68 | };
69 | }
70 |
71 | render() {
72 | this.wrapperElement = window.makeElement('div', {
73 | className: 'wagtail-button-wrapper button button-secondary',
74 | });
75 |
76 | this.buttonElement = window.makeElement('div', {
77 | className: 'wagtail-button',
78 | contentEditable: true,
79 | });
80 |
81 | this.buttonLinkElement = window.makeElement('a', {
82 | "innerHTML": wagtailButtonIcon,
83 | "className": "wagtail-button-icon",
84 | "target": "_blank",
85 | });
86 |
87 | this.chooseNewPageButton = window.makeElement('button', {
88 | "innerHTML": wagtailButtonEditIcon,
89 | "className": "wagtail-button-icon wagtail-button-edit",
90 | });
91 |
92 | this.wrapperElement.appendChild(this.buttonElement);
93 | this.wrapperElement.appendChild(this.buttonLinkElement);
94 | this.wrapperElement.appendChild(this.chooseNewPageButton);
95 |
96 | if (this.data && this.data.url) {
97 | this.wrapperElement.dataset.url = this.data.url;
98 | this.wrapperElement.dataset.pageId = this.data.pageId;
99 | this.wrapperElement.dataset.parentPageId = this.data.parentId;
100 | this.buttonLinkElement.href = this.data.url;
101 | this.buttonElement.innerText = this.data.text;
102 | } else {
103 | window.openChooserModal(this.pageChooser, this.setData.bind(this));
104 | }
105 |
106 | this.chooseNewPageButton.addEventListener('click', () => {
107 | window.openChooserModal(this.pageChooser, this.setData.bind(this));
108 | });
109 |
110 | return super.render();
111 | }
112 |
113 | validate(savedData) {
114 | if (!("pageId" in savedData) || !("text" in savedData)) {
115 | return false;
116 | }
117 |
118 | return true;
119 | }
120 |
121 | save(blockContent) {
122 | this.data = super.save(blockContent);
123 | this.data.text = this.buttonElement.innerText;
124 | this.data.url = this.wrapperElement.dataset.url;
125 | this.data.pageId = this.wrapperElement.dataset.pageId;
126 | this.data.parentId = this.wrapperElement.dataset.parentPageId;
127 | return this.data;
128 | }
129 | }
130 |
131 | window.PageButtonTool = PageButtonTool;
--------------------------------------------------------------------------------
/push-to-github.ps1:
--------------------------------------------------------------------------------
1 | param (
2 | [string]$CommitMessage = "Update to package",
3 | [bool]$Tag = $false,
4 | [string]$TagName = "0.0.0"
5 | )
6 |
7 | if ($TagName -ne "0.0.0") {
8 | $Tag = $true
9 | }
10 |
11 | $ProjectName = "wagtail_editorjs"
12 |
13 |
14 | function IsNumeric ($Value) {
15 | return $Value -match "^[\d\.]+$"
16 | }
17 |
18 | Function GITHUB_Upload {
19 | param (
20 | [parameter(Mandatory=$false)]
21 | [string]$Version
22 | )
23 |
24 | git add .
25 | if ($Tag) {
26 | $gitVersion = "v${Version}"
27 | git commit -m $CommitMessage
28 | git tag $gitVersion
29 | git push -u origin main --tags
30 | } else {
31 | git commit -m $CommitMessage
32 | git push -u origin main
33 | }
34 | }
35 |
36 | Function _NextVersionString {
37 | param (
38 | [string]$Version
39 | )
40 |
41 | $versionParts = $version -split "\."
42 |
43 | $major = [int]$versionParts[0]
44 | $minor = [int]$versionParts[1]
45 | $patch = [int]$versionParts[2] + 1
46 |
47 | # validate integers
48 | if (-not (IsNumeric $major) -or -not (IsNumeric $minor) -or -not (IsNumeric $patch)) {
49 | Write-Host "Invalid version format"
50 | throw "Invalid version format"
51 | }
52 |
53 | if ($patch -gt 9) {
54 | $patch = 0
55 | $minor += 1
56 | }
57 |
58 | if ($minor -gt 9) {
59 | $minor = 0
60 | $major += 1
61 | }
62 |
63 | $newVersion = "$major.$minor.$patch"
64 |
65 | return $newVersion
66 | }
67 |
68 | function PYPI_NextVersion {
69 | param (
70 | [string]$ConfigFile = ".\setup.cfg"
71 | )
72 | # Read file content
73 | $fileContent = Get-Content -Path $ConfigFile
74 |
75 | # Extract the version, increment it, and prepare the updated version string
76 | $versionLine = $fileContent | Where-Object { $_ -match "version\s*=" }
77 | $version = $versionLine -split "=", 2 | ForEach-Object { $_.Trim() } | Select-Object -Last 1
78 | $newVersion = _NextVersionString -Version $version
79 | return $newVersion
80 | }
81 |
82 | function InitRepo {
83 | param (
84 | [string]$ConfigFile = ".\setup.cfg"
85 | )
86 | Write-Host "Initialising repository..."
87 | git init | Out-Host
88 | git add . | Out-Host
89 | git branch -M main | Out-Host
90 | git remote add origin "git@github.com:Nigel2392/${ProjectName}.git" | Out-Host
91 | $version = PYPI_NextVersion -ConfigFile $ConfigFile
92 | Write-Host "Initial version: $version"
93 | return $version
94 | }
95 |
96 | function GITHUB_NextVersion {
97 | param (
98 | [string]$ConfigFile = ".\setup.cfg",
99 | [string]$PyVersionFile = ".\${ProjectName}\__init__.py"
100 | )
101 |
102 |
103 | # Extract the version, increment it, and prepare the updated version string
104 | $version = "$(git tag -l --format='VERSION=%(refname:short)' | Sort-Object -Descending | Select-Object -First 1)" -split "=v", 2 | ForEach-Object { $_.Trim() } | Select-Object -Last 1
105 |
106 | if ($version -And $TagName -eq "0.0.0") {
107 | $newVersion = _NextVersionString -Version $version
108 | Write-Host "Next version (git): $newVersion"
109 | return $newVersion
110 | } else {
111 | if ($TagName -ne "0.0.0") {
112 | # $TagName = $version
113 | # $TagName = _NextVersionString -Version $TagName
114 | Write-Host "Next version (tag): $TagName"
115 | return $TagName
116 | }
117 | $newVersion = InitRepo -ConfigFile $ConfigFile
118 | Write-Host "Next version (init): $newVersion"
119 | return $newVersion
120 | }
121 | }
122 |
123 | Function GITHUB_UpdateVersion {
124 | param (
125 | [string]$ConfigFile = ".\setup.cfg",
126 | [string]$PyVersionFile = ".\${ProjectName}\__init__.py"
127 | )
128 |
129 | $newVersion = GITHUB_NextVersion -ConfigFile $ConfigFile
130 |
131 | Write-Host "Updating version to $newVersion"
132 |
133 | # First update the init file so that in case something goes wrong
134 | # the version doesn't persist in the config file
135 | if (Test-Path $PyVersionFile) {
136 | $initContent = Get-Content -Path $PyVersionFile
137 | $initContent = $initContent -replace "__version__\s*=\s*.+", "__version__ = '$newVersion'"
138 | Set-Content -Path $PyVersionFile -Value $initContent
139 | }
140 |
141 | # Read file content
142 | $fileContent = Get-Content -Path $ConfigFile
143 |
144 | if (Test-Path $ConfigFile) {
145 | # Update the version line in the file content
146 | $updatedContent = $fileContent -replace "version\s*=\s*.+", "version = $newVersion"
147 |
148 | # Write the updated content back to the file
149 | Set-Content -Path $ConfigFile -Value $updatedContent
150 | }
151 |
152 | return $newVersion
153 | }
154 |
155 |
156 | Function _PYPI_DistName {
157 | param (
158 | [string]$Version,
159 | [string]$Append = ".tar.gz"
160 | )
161 |
162 | return "$ProjectName-$Version$Append"
163 | }
164 |
165 | Function PYPI_Build {
166 | py .\setup.py sdist
167 | }
168 |
169 | Function PYPI_Check {
170 | param (
171 | [string]$Version
172 | )
173 |
174 | $distFile = _PYPI_DistName -Version $Version
175 | py -m twine check "./dist/${distFile}"
176 | }
177 |
178 | Function PYPI_Upload {
179 | param (
180 | [string]$Version
181 | )
182 |
183 | $distFile = _PYPI_DistName -Version $Version
184 | python3 -m twine upload "./dist/${distFile}"
185 | }
186 |
187 | if ($Tag) {
188 | $version = GITHUB_UpdateVersion # Increment the package version (setup.cfg)
189 | GITHUB_Upload -Version $version # Upload the package (git push)
190 | PYPI_Build # Build the package (python setup.py sdist)
191 | PYPI_Check -Version $version # Check the package (twine check dist/)
192 | PYPI_Upload -Version $version # Upload the package (twine upload dist/)
193 | } else {
194 | GITHUB_Upload # Upload the package
195 | }
196 |
197 |
198 |
199 |
200 |
--------------------------------------------------------------------------------
/wagtail_editorjs/test/core/tests/test_render.py:
--------------------------------------------------------------------------------
1 | from typing import Any
2 | from django.template.loader import render_to_string
3 | from django.utils.safestring import mark_safe
4 | from bs4 import BeautifulSoup
5 |
6 | from .base import BaseEditorJSTest
7 | from wagtail_editorjs.render import render_editorjs_html
8 | from wagtail_editorjs.registry import (
9 | EditorJSTune,
10 | EditorJSFeature,
11 | EditorJSElement,
12 | EDITOR_JS_FEATURES,
13 | )
14 |
15 |
16 | class TestEditorJSTune(EditorJSTune):
17 | allowed_attributes = {
18 | "*": ["data-testing-id"],
19 | }
20 | klass = 1
21 |
22 | def tune_element(self, element: EditorJSElement, tune_value: Any, context=None) -> EditorJSElement:
23 | element.attrs["data-testing-id"] = tune_value
24 | return element
25 |
26 |
27 | # Create your tests here.
28 | class TestEditorJSFeatures(BaseEditorJSTest):
29 |
30 | def setUp(self) -> None:
31 | super().setUp()
32 | self.tune = TestEditorJSTune(
33 | "test_tune_feature",
34 | None
35 | )
36 | EDITOR_JS_FEATURES.register(
37 | "test_tune_feature",
38 | self.tune,
39 | )
40 |
41 | def test_editorjs_features(self):
42 |
43 | html = []
44 | test_data = []
45 | for i, feature in enumerate(EDITOR_JS_FEATURES.features.values()):
46 | test_data_list = feature.get_test_data()
47 | if not isinstance(feature, (EditorJSFeature))\
48 | or not test_data_list:
49 | continue
50 |
51 | for j, data in enumerate(test_data_list):
52 | test_data_list[j] = {
53 | "id": "test_id_{}_{}".format(i, j),
54 | "type": feature.tool_name,
55 | "data": data,
56 | "tunes": {
57 | "test_tune_feature": "test_id_{}_{}_tune".format(i, j)
58 | }
59 | }
60 |
61 | test_data.extend(test_data_list)
62 |
63 | for data in test_data_list:
64 | if hasattr(feature, "render_block_data"):
65 | tpl = feature.render_block_data(data)
66 | tpl = self.tune.tune_element(tpl, data["tunes"]["test_tune_feature"])
67 | html.append(tpl)
68 |
69 | rendered_1 = render_editorjs_html(
70 | EDITOR_JS_FEATURES.keys(),
71 | {"blocks": test_data},
72 | clean=False,
73 | )
74 |
75 | rendered_2 = render_to_string(
76 | "wagtail_editorjs/rich_text.html",
77 | {"html": mark_safe("\n".join([str(h) for h in html]))}
78 | )
79 |
80 | soup1 = BeautifulSoup(rendered_1, "html.parser")
81 | soup2 = BeautifulSoup(rendered_2, "html.parser")
82 |
83 | d1 = soup1.decode(False)
84 | d2 = soup2.decode(False)
85 | self.assertHTMLEqual(
86 | d1, d2,
87 | msg=(
88 | f"The rendered HTML for feature {feature} does not match the expected output.\n"
89 | "This might be due to a change in the rendering process.\n\n"
90 | "Expected: {expected}\n\n"
91 | "Got: {got}" % {
92 | "expected": d1,
93 | "got": d2,
94 | }
95 | )
96 | )
97 |
98 | def test_cleaned_editorjs_features(self):
99 |
100 | html = []
101 | test_data = []
102 | for i, feature in enumerate(EDITOR_JS_FEATURES.features.values()):
103 | test_data_list = feature.get_test_data()
104 | if not isinstance(feature, (EditorJSFeature))\
105 | or not test_data_list:
106 | continue
107 |
108 | for j, data in enumerate(test_data_list):
109 | test_data_list[j] = {
110 | "id": "test_id_{}_{}".format(i, j),
111 | "type": feature.tool_name,
112 | "data": data,
113 | "tunes": {
114 | "test_tune_feature": "test_id_{}_{}_tune".format(i, j)
115 | }
116 | }
117 |
118 | for data in test_data_list:
119 | if hasattr(feature, "render_block_data"):
120 | tpl = feature.render_block_data(data)
121 | tpl = self.tune.tune_element(tpl, data["tunes"]["test_tune_feature"])
122 | html.append(tpl)
123 |
124 | test_data.extend(test_data_list)
125 |
126 | rendered = render_editorjs_html(
127 | EDITOR_JS_FEATURES.keys(),
128 | {"blocks": test_data},
129 | clean=True,
130 | )
131 |
132 | soup = BeautifulSoup(rendered, "html.parser")
133 |
134 | for i, data in enumerate(test_data):
135 | block = soup.find(attrs={"data-testing-id": data["tunes"]["test_tune_feature"]})
136 |
137 | if not block:
138 | self.fail(
139 | f"Block with id {data['tunes']['test_tune_feature']} not found.\n"
140 | "The tune might not have been properly applied. Check the test data.\n\n"
141 | f"Test data: {data}\n\n"
142 | f"Soup: {soup}"
143 | )
144 |
145 | feature = EDITOR_JS_FEATURES[data["type"]]
146 | element = feature.render_block_data(data)
147 | element = self.tune.tune_element(element, data["tunes"]["test_tune_feature"])
148 |
149 | soup_element = BeautifulSoup(str(element), "html.parser")
150 |
151 | self.assertHTMLEqual(
152 | str(block).replace("\n", "").strip(), str(soup_element).replace("\n", "").strip(),
153 | msg=(
154 | f"Block with feature {feature} ({i}) does not match the expected output.\n"
155 | "Something has gone wrong with the cleaning process.\n\n"
156 | "Expected: {expected}\n\n"
157 | "Got: {got}" % {
158 | "feature": data['tunes']['test_tune_feature'],
159 | "expected": str(soup_element).replace('\n', '').strip(),
160 | "got": str(block).replace('\n', '').strip(),
161 | }
162 | )
163 | )
164 |
165 |
166 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | wagtail_editorjs
2 | ================
3 |
4 | *Check out [Awesome Wagtail](https://github.com/springload/awesome-wagtail) for more awesome packages and resources from the Wagtail community.*
5 |
6 | A Wagtail EditorJS widget with page/image chooser support, document support and more!
7 |
8 | ## Add features
9 |
10 | * [Add an EditorJS feature](https://github.com/Nigel2392/wagtail_editorjs/blob/main/docs/editorjs_feature.md "Simple Image Feature")
11 | * [Add an EditorJS tune](https://github.com/Nigel2392/wagtail_editorjs/blob/main/docs/tunes.md "text-alignment-tune") (Already exists in `wagtail_editorjs`, just an example.)
12 |
13 | Quick start
14 | -----------
15 |
16 | 1. Add 'wagtail_editorjs' to your INSTALLED_APPS setting like this:
17 |
18 | ```
19 | INSTALLED_APPS = [
20 | ...,
21 | 'wagtail_editorjs',
22 | ]
23 | ```
24 |
25 | 2. Add the HTML to your template:
26 |
27 | ```django-html
28 |
29 | {% load editorjs %}
30 |
31 | {# CSS files for features #}
32 | {% editorjs_static "css" %}
33 |
34 | {% editorjs self.editor_field %}
35 |
36 | {# JS files for features #}
37 | {% editorjs_static "js" %}
38 | ```
39 |
40 | 3. Add the field to your model:
41 |
42 | ```python
43 | ...
44 | from wagtail_editorjs.fields import EditorJSField
45 | from wagtail_editorjs.blocks import EditorJSBlock
46 |
47 |
48 | class HomePage(Page):
49 | content_panels = [
50 | FieldPanel("editor_field"),
51 | FieldPanel("content"),
52 | ]
53 | editor_field = EditorJSField(
54 | # All supported features
55 | features=[
56 | 'attaches',
57 | 'background-color-tune',
58 | 'button',
59 | 'checklist',
60 | 'code',
61 | 'delimiter',
62 | 'document',
63 | 'drag-drop',
64 | 'header',
65 | 'image',
66 | 'images',
67 | 'inline-code',
68 | 'link',
69 | 'link-autocomplete',
70 | 'marker',
71 | 'nested-list',
72 | 'paragraph',
73 | 'quote',
74 | 'raw',
75 | 'table',
76 | 'text-alignment-tune',
77 | 'text-color-tune',
78 | 'text-variant-tune',
79 | 'tooltip',
80 | 'underline',
81 | 'undo-redo',
82 | 'warning'
83 | ],
84 | blank=True,
85 | null=True,
86 | )
87 |
88 | # Or as a block
89 | content = fields.StreamField([
90 | ('editorjs', EditorJSBlock(features=[
91 | # ... same as before
92 | ])),
93 | ], blank=True, use_json_field=True)
94 | ```
95 |
96 | ## List features
97 |
98 | This readme might not fully reflect which features are available.
99 |
100 | To find this out - you can:
101 |
102 | 1. start the python shell
103 |
104 | ```bash
105 | py ./manage.py shell
106 | ```
107 |
108 | 2. Print all the available features:
109 |
110 | ```python
111 | from wagtail_editorjs.registry import EDITOR_JS_FEATURES
112 | print(EDITOR_JS_FEATURES.keys())
113 | dict_keys([... all registered features ...])
114 | ```
115 |
116 | ## Register a Wagtail block as a feature
117 |
118 | **Warning, this is not available after wagtail 6.2 due to validation errors, TODO: fix this**
119 |
120 | It is also possible to register a Wagtail block as a feature.
121 |
122 | It is important to note that the block must be a `StructBlock` or a subclass of `StructBlock`.
123 |
124 | It is **not** allowed to be or include:
125 |
126 | * A `StreamBlock` (mainly due to styling issues)
127 | * A `ListBlock` (mainly due to styling issues)
128 | * A `RichTextBlock` (cannot initialize)
129 |
130 | *Help with these issues is highly appreciated!*
131 |
132 | Example:
133 |
134 | ```python
135 | from wagtail import hooks
136 | from wagtail_editorjs.features import (
137 | WagtailBlockFeature,
138 | EditorJSFeatureStructBlock,
139 | )
140 | from wagtail_editorjs.registry import (
141 | EditorJSFeatures,
142 | )
143 | from wagtail_editorjs.hooks import REGISTER_HOOK_NAME
144 |
145 | from wagtail import blocks
146 |
147 | class HeadingBlock(blocks.StructBlock):
148 | title = blocks.CharBlock()
149 | subtitle = blocks.CharBlock()
150 |
151 | class TextBlock(EditorJSFeatureStructBlock):
152 | heading = HeadingBlock()
153 | body = blocks.TextBlock()
154 |
155 | class Meta:
156 | template = "myapp/text_block.html"
157 | allowed_tags = ["h1", "h2", "p"]
158 | # Html looks like:
159 | #
{{ self.heading.title }}
160 | #
{{ self.heading.subtitle }}
161 | #
{{ self.body }}
162 |
163 | @hooks.register(REGISTER_HOOK_NAME)
164 | def register_editor_js_features(registry: EditorJSFeatures):
165 |
166 | registry.register(
167 | "wagtail-text-block",
168 | WagtailBlockFeature(
169 | "wagtail-text-block",
170 | block=TextBlock(),
171 | ),
172 | )
173 | ```
174 |
175 | The block will then be rendered as any structblock, but it will be wrapped in a div with the class `wagtail-text-block` (the feature name).
176 |
177 | Example:
178 |
179 | ```html
180 |
181 |
My title
182 |
My subtitle
183 |
My body
184 |
185 | ```
186 |
187 | ## Settings
188 |
189 | ### `EDITORJS_CLEAN_HTML`
190 |
191 | Default: `True`
192 | Clean the HTML output on rendering.
193 | This happens every time the field is rendered.
194 | It might be smart to set up some sort of caching mechanism.
195 | Optionally; cleaning can be FORCED by passing `clean=True` or `False` to the `render_editorjs_html` function.
196 |
197 | ### `EDITORJS_ADD_BLOCK_ID`
198 |
199 | Default: `true`
200 | Add a block ID to each editorJS block when rendering.
201 | This is useful for targeting the block with JavaScript,
202 | or possibly creating some link from frontend to admin area.
203 |
204 | ### `EDITORJS_BLOCK_ID_ATTR`
205 |
206 | Default: `data-editorjs-block-id`
207 | The attribute name to use for the block ID.
208 | This is only used if `ADD_BLOCK_ID` is True.
209 |
210 | ### `EDITORJS_USE_FULL_URLS`
211 |
212 | Default: `False`
213 | Use full urls if the request is available in the EditorJS rendering context.
214 |
--------------------------------------------------------------------------------
/wagtail_editorjs/static/wagtail_editorjs/js/tools/wagtail-link.js:
--------------------------------------------------------------------------------
1 | const wagtailLinkIcon = ``;
7 |
8 |
9 |
10 | class WagtailLinkTool extends window.BaseWagtailChooserTool {
11 | constructor({ api, config }) {
12 | super({ api, config })
13 | this.colorPicker = null;
14 | }
15 |
16 | get iconHTML() {
17 | return wagtailLinkIcon;
18 | }
19 |
20 | static get chooserType() {
21 | return 'page';
22 | }
23 |
24 | newChooser() {
25 | let urlParams = {
26 | page_type: this.config.page_type || 'wagtailcore.page',
27 | allow_external_link: this.config.allow_external_link || true,
28 | allow_email_link: this.config.allow_email_link || true,
29 | allow_phone_link: this.config.allow_phone_link || true,
30 | allow_anchor_link: this.config.allow_anchor_link || true,
31 | };
32 |
33 | const cfg = {
34 | url: this.config.chooserUrls.pageChooser,
35 | urlParams: urlParams,
36 | onload: window.PAGE_CHOOSER_MODAL_ONLOAD_HANDLERS,
37 | modelNames: ['wagtailcore.page'],
38 | };
39 |
40 | return new window.PageChooser(this.config.chooserId, cfg);
41 | }
42 |
43 | showActions(wrapperTag) {
44 | this.pageURLInput.value = wrapperTag.href;
45 |
46 | let chooseNewPageFunc = null;
47 | chooseNewPageFunc = (e) => {
48 | this.setDataOnWrapper(wrapperTag, this.state);
49 | this.pageURLInput.value = this.state.url;
50 | this.chooser.input.removeEventListener('change', chooseNewPageFunc);
51 | };
52 |
53 | this.chooseNewPageButton.onclick = (() => {
54 | this.chooser.openChooserModal();
55 | this.chooser.input.addEventListener('change', chooseNewPageFunc);
56 | });
57 |
58 | this.api.tooltip.onHover(this.chooseNewPageButton, this.api.i18n.t('Choose new ' + this.constructor["chooserType"]), {
59 | placement: 'top',
60 | hidingDelay: 200,
61 | });
62 |
63 | this.targetSelect.onchange = (e) => {
64 | if (e.target.value) {
65 | this.wrapperTag.target = e.target.value;
66 | this.wrapperTag.dataset.target = e.target.value;
67 | } else if (this.wrapperTag.target) {
68 | this.wrapperTag.removeAttribute('target');
69 | delete this.wrapperTag.dataset.target;
70 | }
71 | };
72 |
73 | this.relSelect.onchange = (e) => {
74 | if (!e.target.value && this.pageURLInput.rel) {
75 | this.wrapperTag.removeAttribute('rel');
76 | delete this.wrapperTag.dataset.rel;
77 | } else {
78 | this.wrapperTag.rel = e.target.value;
79 | this.wrapperTag.dataset.rel = e.target.value;
80 | }
81 | }
82 |
83 | this.relSelect.value = wrapperTag.rel || '';
84 | this.targetSelect.value = wrapperTag.target || '';
85 |
86 | this.container.hidden = false;
87 |
88 |
89 | }
90 |
91 | hideActions() {
92 | this.container.hidden = true;
93 | this.pageURLInput.value = '';
94 | this.chooseNewPageButton.onclick = null;
95 | this.pageURLInput.onchange = null;
96 | this.targetSelect.onchange = null;
97 | this.relSelect.onchange = null;
98 | this.chooseNewPageButton.classList.remove(
99 | this.api.styles.inlineToolButtonActive
100 | );
101 | }
102 |
103 | renderActions() {
104 | this.container = document.createElement('div');
105 | this.container.classList.add("wagtail-link-tool-actions", "column");
106 | this.container.hidden = true;
107 |
108 | const btnContainer = document.createElement('div');
109 | btnContainer.classList.add("wagtail-link-tool-actions");
110 |
111 | this.chooseNewPageButton = document.createElement('button');
112 | this.chooseNewPageButton.type = 'button';
113 | this.chooseNewPageButton.innerHTML = wagtailLinkIcon;
114 | this.chooseNewPageButton.dataset.chooserActionChoose = 'true';
115 | this.chooseNewPageButton.classList.add(
116 | this.api.styles.inlineToolButton,
117 | )
118 |
119 | const selectContainer = document.createElement('div');
120 | selectContainer.classList.add("wagtail-link-tool-actions");
121 |
122 | this.targetSelect = document.createElement('select');
123 | this.targetSelect.innerHTML = `
124 |
125 |
126 |
127 | `;
128 |
129 | this.relSelect = document.createElement('select');
130 | this.relSelect.innerHTML = `
131 |
132 |
133 |
134 |
135 | `;
136 |
137 | this.pageURLInput = document.createElement('input');
138 | this.pageURLInput.type = 'text';
139 | this.pageURLInput.disabled = true;
140 | this.pageURLInput.placeholder = this.api.i18n.t('URL');
141 | this.pageURLInput.classList.add(
142 | this.api.styles.input,
143 | this.api.styles.inputUrl,
144 | );
145 |
146 |
147 | selectContainer.appendChild(this.targetSelect);
148 | selectContainer.appendChild(this.relSelect);
149 |
150 | btnContainer.appendChild(this.pageURLInput);
151 | btnContainer.appendChild(this.chooseNewPageButton);
152 |
153 | this.container.appendChild(btnContainer);
154 | this.container.appendChild(selectContainer);
155 |
156 | return this.container;
157 | }
158 | }
159 |
160 | window.WagtailLinkTool = WagtailLinkTool;
--------------------------------------------------------------------------------
/wagtail_editorjs/static/wagtail_editorjs/vendor/editorjs/tools/header.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Skipped minification because the original files appears to be already minified.
3 | * Original file: /npm/@editorjs/header@2.8.1/dist/header.umd.js
4 | *
5 | * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
6 | */
7 | (function(){"use strict";try{if(typeof document<"u"){var e=document.createElement("style");e.appendChild(document.createTextNode(".ce-header{outline:none}.ce-header p,.ce-header div{padding:0!important;margin:0!important}.ce-header[contentEditable=true][data-placeholder]:before{position:absolute;content:attr(data-placeholder);color:#707684;font-weight:400;display:none;cursor:text}.ce-header[contentEditable=true][data-placeholder]:empty:before{display:block}.ce-header[contentEditable=true][data-placeholder]:empty:focus:before{display:none}")),document.head.appendChild(e)}}catch(t){console.error("vite-plugin-css-injected-by-js",t)}})();
8 | (function(n,r){typeof exports=="object"&&typeof module<"u"?module.exports=r():typeof define=="function"&&define.amd?define(r):(n=typeof globalThis<"u"?globalThis:n||self,n.Header=r())})(this,function(){"use strict";const n="",r='',o='',a='',h='',d='',u='',g='';/**
9 | * Header block for the Editor.js.
10 | *
11 | * @author CodeX (team@ifmo.su)
12 | * @copyright CodeX 2018
13 | * @license MIT
14 | * @version 2.0.0
15 | */class c{constructor({data:e,config:t,api:s,readOnly:i}){this.api=s,this.readOnly=i,this._CSS={block:this.api.styles.block,wrapper:"ce-header"},this._settings=t,this._data=this.normalizeData(e),this._element=this.getTag()}normalizeData(e){const t={};return typeof e!="object"&&(e={}),t.text=e.text||"",t.level=parseInt(e.level)||this.defaultLevel.number,t}render(){return this._element}renderSettings(){return this.levels.map(e=>({icon:e.svg,label:this.api.i18n.t(`Heading ${e.number}`),onActivate:()=>this.setLevel(e.number),closeOnActivate:!0,isActive:this.currentLevel.number===e.number}))}setLevel(e){this.data={level:e,text:this.data.text}}merge(e){const t={text:this.data.text+e.text,level:this.data.level};this.data=t}validate(e){return e.text.trim()!==""}save(e){return{text:e.innerHTML,level:this.currentLevel.number}}static get conversionConfig(){return{export:"text",import:"text"}}static get sanitize(){return{level:!1,text:{}}}static get isReadOnlySupported(){return!0}get data(){return this._data.text=this._element.innerHTML,this._data.level=this.currentLevel.number,this._data}set data(e){if(this._data=this.normalizeData(e),e.level!==void 0&&this._element.parentNode){const t=this.getTag();t.innerHTML=this._element.innerHTML,this._element.parentNode.replaceChild(t,this._element),this._element=t}e.text!==void 0&&(this._element.innerHTML=this._data.text||"")}getTag(){const e=document.createElement(this.currentLevel.tag);return e.innerHTML=this._data.text||"",e.classList.add(this._CSS.wrapper),e.contentEditable=this.readOnly?"false":"true",e.dataset.placeholder=this.api.i18n.t(this._settings.placeholder||""),e}get currentLevel(){let e=this.levels.find(t=>t.number===this._data.level);return e||(e=this.defaultLevel),e}get defaultLevel(){if(this._settings.defaultLevel){const e=this.levels.find(t=>t.number===this._settings.defaultLevel);if(e)return e;console.warn("(ง'̀-'́)ง Heading Tool: the default level specified was not found in available levels")}return this.levels[1]}get levels(){const e=[{number:1,tag:"H1",svg:r},{number:2,tag:"H2",svg:o},{number:3,tag:"H3",svg:a},{number:4,tag:"H4",svg:h},{number:5,tag:"H5",svg:d},{number:6,tag:"H6",svg:u}];return this._settings.levels?e.filter(t=>this._settings.levels.includes(t.number)):e}onPaste(e){const t=e.detail.data;let s=this.defaultLevel.number;switch(t.tagName){case"H1":s=1;break;case"H2":s=2;break;case"H3":s=3;break;case"H4":s=4;break;case"H5":s=5;break;case"H6":s=6;break}this._settings.levels&&(s=this._settings.levels.reduce((i,l)=>Math.abs(l-s)',g='';function d(){const s=document.activeElement,t=window.getSelection().getRangeAt(0),n=t.cloneRange();return n.selectNodeContents(s),n.setStart(t.endContainer,t.endOffset),n.extractContents()}function f(s){const e=document.createElement("div");return e.appendChild(s),e.innerHTML}function o(s,e=null,t={}){const n=document.createElement(s);Array.isArray(e)?n.classList.add(...e):e&&n.classList.add(e);for(const i in t)n[i]=t[i];return n}function m(s){return s.innerHTML.replace(" "," ").trim()}function p(s,e=!1,t=void 0){const n=document.createRange(),i=window.getSelection();n.selectNodeContents(s),t!==void 0&&(n.setStart(s,t),n.setEnd(s,t)),n.collapse(e),i.removeAllRanges(),i.addRange(n)}Element.prototype.matches||(Element.prototype.matches=Element.prototype.msMatchesSelector||Element.prototype.webkitMatchesSelector),Element.prototype.closest||(Element.prototype.closest=function(s){let e=this;if(!document.documentElement.contains(e))return null;do{if(e.matches(s))return e;e=e.parentElement||e.parentNode}while(e!==null&&e.nodeType===1);return null});class C{static get isReadOnlySupported(){return!0}static get enableLineBreaks(){return!0}static get toolbox(){return{icon:g,title:"Checklist"}}static get conversionConfig(){return{export:e=>e.items.map(({text:t})=>t).join(". "),import:e=>({items:[{text:e,checked:!1}]})}}constructor({data:e,config:t,api:n,readOnly:i}){this._elements={wrapper:null,items:[]},this.readOnly=i,this.api=n,this.data=e||{}}render(){return this._elements.wrapper=o("div",[this.CSS.baseBlock,this.CSS.wrapper]),this.data.items||(this.data.items=[{text:"",checked:!1}]),this.data.items.forEach(e=>{const t=this.createChecklistItem(e);this._elements.wrapper.appendChild(t)}),this.readOnly?this._elements.wrapper:(this._elements.wrapper.addEventListener("keydown",e=>{const[t,n]=[13,8];switch(e.keyCode){case t:this.enterPressed(e);break;case n:this.backspace(e);break}},!1),this._elements.wrapper.addEventListener("click",e=>{this.toggleCheckbox(e)}),this._elements.wrapper)}save(){let e=this.items.map(t=>{const n=this.getItemInput(t);return{text:m(n),checked:t.classList.contains(this.CSS.itemChecked)}});return e=e.filter(t=>t.text.trim().length!==0),{items:e}}validate(e){return!!e.items.length}toggleCheckbox(e){const t=e.target.closest(`.${this.CSS.item}`),n=t.querySelector(`.${this.CSS.checkboxContainer}`);n.contains(e.target)&&(t.classList.toggle(this.CSS.itemChecked),n.classList.add(this.CSS.noHover),n.addEventListener("mouseleave",()=>this.removeSpecialHoverBehavior(n),{once:!0}))}createChecklistItem(e={}){const t=o("div",this.CSS.item),n=o("span",this.CSS.checkbox),i=o("div",this.CSS.checkboxContainer),a=o("div",this.CSS.textField,{innerHTML:e.text?e.text:"",contentEditable:!this.readOnly});return e.checked&&t.classList.add(this.CSS.itemChecked),n.innerHTML=c,i.appendChild(n),t.appendChild(i),t.appendChild(a),t}enterPressed(e){e.preventDefault();const t=this.items,n=document.activeElement.closest(`.${this.CSS.item}`);if(t.indexOf(n)===t.length-1&&m(this.getItemInput(n)).length===0){const x=this.api.blocks.getCurrentBlockIndex();n.remove(),this.api.blocks.insert(),this.api.caret.setToBlock(x+1);return}const u=d(),h=f(u),r=this.createChecklistItem({text:h,checked:!1});this._elements.wrapper.insertBefore(r,n.nextSibling),p(this.getItemInput(r),!0)}backspace(e){const t=e.target.closest(`.${this.CSS.item}`),n=this.items.indexOf(t),i=this.items[n-1];if(!i||!(window.getSelection().focusOffset===0))return;e.preventDefault();const h=d(),r=this.getItemInput(i),k=r.childNodes.length;r.appendChild(h),p(r,void 0,k),t.remove()}get CSS(){return{baseBlock:this.api.styles.block,wrapper:"cdx-checklist",item:"cdx-checklist__item",itemChecked:"cdx-checklist__item--checked",noHover:"cdx-checklist__item-checkbox--no-hover",checkbox:"cdx-checklist__item-checkbox-check",textField:"cdx-checklist__item-text",checkboxContainer:"cdx-checklist__item-checkbox"}}get items(){return Array.from(this._elements.wrapper.querySelectorAll(`.${this.CSS.item}`))}removeSpecialHoverBehavior(e){e.classList.remove(this.CSS.noHover)}getItemInput(e){return e.querySelector(`.${this.CSS.textField}`)}}return C});
9 |
--------------------------------------------------------------------------------
/wagtail_editorjs/features/documents.py:
--------------------------------------------------------------------------------
1 | from typing import Any, TYPE_CHECKING
2 | from django import forms
3 | from django.urls import reverse
4 | from django.utils.translation import gettext_lazy as _
5 | from django.views.decorators.csrf import csrf_exempt
6 | from django.utils.safestring import mark_safe
7 | from django.http import (
8 | JsonResponse,
9 | )
10 |
11 | from wagtail.models import Collection
12 | from wagtail.documents import (
13 | get_document_model,
14 | )
15 | from wagtail.documents.forms import (
16 | get_document_form,
17 | )
18 | if TYPE_CHECKING:
19 | from wagtail.documents.models import AbstractDocument
20 | from ..settings import (
21 | USE_FULL_URLS,
22 | )
23 | from ..registry import (
24 | EditorJSFeature,
25 | EditorJSBlock,
26 | EditorJSElement,
27 | FeatureViewMixin,
28 | )
29 |
30 | BYTE_SIZE_STEPS = [_("Bytes"), _("Kilobytes"), _("Megabytes"), _("Gigabytes"), _("Terabytes")]
31 |
32 | def filesize_to_human_readable(size: int) -> str:
33 | for unit in BYTE_SIZE_STEPS:
34 | if size < 1024:
35 | break
36 | size /= 1024
37 | return f"{size:.0f} {unit}"
38 |
39 |
40 |
41 | Document = get_document_model()
42 | DocumentForm = get_document_form(Document)
43 |
44 |
45 | class AttachesFeature(FeatureViewMixin, EditorJSFeature):
46 | allowed_tags = [
47 | "div", "p", "span", "a",
48 | "svg", "path",
49 | ]
50 | allowed_attributes = {
51 | "div": ["class"],
52 | "p": ["class"],
53 | "span": ["class"],
54 | "a": ["class", "href", "title"],
55 | "svg": ["xmlns", "width", "height", "fill", "class", "viewBox"],
56 | "path": ["d"],
57 | }
58 | klass="CSRFAttachesTool"
59 | js=[
60 | "wagtail_editorjs/vendor/editorjs/tools/attaches.js",
61 | "wagtail_editorjs/js/tools/attaches.js",
62 | ],
63 |
64 |
65 | def get_config(self, context: dict[str, Any] = None) -> dict:
66 | config = super().get_config(context)
67 | config.setdefault("config", {})
68 | config["config"]["endpoint"] = reverse(f"wagtail_editorjs:{self.tool_name}")
69 | return config
70 |
71 |
72 | def validate(self, data: Any):
73 | super().validate(data)
74 |
75 | if "file" not in data["data"]:
76 | raise forms.ValidationError("Invalid file value")
77 |
78 | if "id" not in data["data"]["file"] and not data["data"]["file"]["id"] and "url" not in data["data"]["file"]:
79 | raise forms.ValidationError("Invalid id/url value")
80 |
81 | if "title" not in data["data"]:
82 | raise forms.ValidationError("Invalid title value")
83 |
84 |
85 | def render_block_data(self, block: EditorJSBlock, context = None) -> EditorJSElement:
86 |
87 | document_id = block["data"]["file"]["id"]
88 | document = Document.objects.get(pk=document_id)
89 | url = document.url
90 |
91 | if not any([url.startswith(i) for i in ["http://", "https://", "//"]])\
92 | and context\
93 | and "request" in context\
94 | and USE_FULL_URLS:
95 | request = context.get("request")
96 | if request:
97 | url = request.build_absolute_uri(url)
98 |
99 | if block["data"]["title"]:
100 | title = block["data"]["title"]
101 | else:
102 | if document:
103 | title = document.title
104 | else:
105 | title = url
106 |
107 | return EditorJSElement(
108 | "div",
109 | [
110 | EditorJSElement(
111 | "p",
112 | EditorJSElement(
113 | "a",
114 | title,
115 | attrs={"href": url},
116 | ),
117 | attrs={"class": "attaches-title"},
118 | ),
119 | EditorJSElement(
120 | "span",
121 | filesize_to_human_readable(document.file.size),
122 | attrs={"class": "attaches-size"},
123 | ),
124 | EditorJSElement(
125 | "a",
126 | mark_safe(""""""),
130 | attrs={
131 | "title": _("Download"),
132 | "href": url,
133 | "class": "attaches-link",
134 | # "data-id": document_id,
135 | },
136 | )
137 | ],
138 | attrs={"class": "attaches"},
139 | )
140 |
141 |
142 | @classmethod
143 | def get_test_data(cls):
144 | instance = Document.objects.first()
145 | return [
146 | {
147 | "file": {
148 | "id": instance.pk,
149 | },
150 | "title": "Document",
151 | },
152 | ]
153 |
154 | @csrf_exempt
155 | def handle_post(self, request):
156 | file = request.FILES.get('file')
157 | if not file:
158 | return JsonResponse({
159 | 'success': False,
160 | 'errors': {
161 | 'file': ["This field is required."]
162 | }
163 | }, status=400)
164 |
165 | filename = file.name
166 | title = request.POST.get('title', filename)
167 |
168 | collection = Collection.get_first_root_node().id
169 | form = DocumentForm({ 'title': title, 'collection': collection }, request.FILES)
170 | if form.is_valid():
171 | document: AbstractDocument = form.save(commit=False)
172 |
173 | hash = document.get_file_hash()
174 | existing = Document.objects.filter(file_hash=hash)
175 | if existing.exists():
176 | exists: AbstractDocument = existing.first()
177 | return JsonResponse({
178 | 'success': True,
179 | 'file': {
180 | 'id': exists.pk,
181 | 'title': exists.title,
182 | 'size': exists.file.size,
183 | 'url': exists.url,
184 | 'upload_replaced': True,
185 | 'reuploaded_by_user': request.user.pk,
186 | }
187 | })
188 |
189 | document.uploaded_by_user = request.user
190 | document.save()
191 | return JsonResponse({
192 | 'success': True,
193 | 'file': {
194 | 'id': document.pk,
195 | 'title': document.title,
196 | 'size': document.file.size,
197 | 'url': document.url,
198 | 'upload_replaced': False,
199 | 'reuploaded_by_user': None,
200 | }
201 | })
202 | else:
203 | return JsonResponse({
204 | 'success': False,
205 | 'errors': form.errors,
206 | }, status=400)
207 |
208 |
--------------------------------------------------------------------------------
/wagtail_editorjs/forms.py:
--------------------------------------------------------------------------------
1 | from django.utils.functional import cached_property
2 | from django import forms
3 | from django.forms import (
4 | fields as formfields,
5 | widgets
6 | )
7 | from wagtail import hooks
8 |
9 | from datetime import datetime
10 |
11 | from .hooks import (
12 | BUILD_CONFIG_HOOK,
13 | )
14 | from .registry import (
15 | EDITOR_JS_FEATURES,
16 | get_features,
17 | TemplateNotSpecifiedError,
18 | )
19 |
20 | def _get_feature_scripts(feature, method, *args, list_obj = None, **kwargs):
21 | get_scripts = getattr(feature, method, None)
22 | if get_scripts is None:
23 | raise AttributeError(f"Feature {feature} does not have a {method} method")
24 |
25 | scripts = get_scripts(*args, **kwargs)
26 |
27 | if list_obj is None:
28 | list_obj = []
29 |
30 | for file in get_scripts():
31 | if file not in list_obj:
32 | if isinstance(file, (list, tuple)):
33 | list_obj.extend(file)
34 | else:
35 | list_obj.append(file)
36 | return scripts
37 |
38 | class EditorJSWidget(widgets.Input):
39 | """
40 | A widget which renders the EditorJS editor.
41 |
42 | All features are allowed to register CSS and JS files.
43 |
44 | They can also optionally include sub-templates
45 | inside of the widget container.
46 | """
47 | template_name = 'wagtail_editorjs/widgets/editorjs.html'
48 | accepts_features = True
49 | input_type = 'hidden'
50 |
51 | def __init__(self, features: list[str] = None, tools_config: dict = None, attrs: dict = None):
52 | super().__init__(attrs)
53 |
54 | self.features = get_features(features)
55 | self.tools_config = tools_config or {}
56 | self.autofocus = self.attrs.get('autofocus', False)
57 | self.placeholder = self.attrs.get('placeholder', "")
58 |
59 | def build_attrs(self, base_attrs, extra_attrs):
60 | attrs = super().build_attrs(base_attrs, extra_attrs)
61 | attrs['data-controller'] = 'editorjs-widget'
62 | return attrs
63 |
64 | def get_context(self, name, value, attrs):
65 | context = super().get_context(name, value, attrs)
66 | config = EDITOR_JS_FEATURES.build_config(self.features, context)
67 | config["holder"] = f"{context['widget']['attrs']['id']}-wagtail-editorjs-widget"
68 |
69 | tools = config.get('tools', {})
70 |
71 | for tool_name, tool_config in self.tools_config.items():
72 | if tool_name in tools:
73 | cfg = tools[tool_name]
74 | cpy = tool_config.copy()
75 | cpy.update(cfg)
76 | tools[tool_name] = cpy
77 | else:
78 | raise ValueError(f"Tool {tool_name} not found in tools; did you include the feature?")
79 |
80 | for hook in hooks.get_hooks(BUILD_CONFIG_HOOK):
81 | hook(self, context, config)
82 |
83 | context['widget']['features'] = self.features
84 | inclusion_templates = []
85 | for feature in self.features:
86 | try:
87 | inclusion_templates.append(
88 | EDITOR_JS_FEATURES[feature].render_template(context)
89 | )
90 | except TemplateNotSpecifiedError:
91 | pass
92 |
93 | context['widget']['inclusion_templates'] = inclusion_templates
94 | context['widget']['config'] = config
95 | return context
96 |
97 | @cached_property
98 | def media(self):
99 | js = [
100 | "wagtail_editorjs/vendor/editorjs/editorjs.umd.js",
101 | "wagtail_editorjs/js/editorjs-widget.js",
102 | "wagtail_editorjs/js/tools/wagtail-block-tool.js",
103 | "wagtail_editorjs/js/tools/wagtail-inline-tool.js",
104 | ]
105 | css = [
106 | "wagtail_editorjs/css/editorjs-widget.css",
107 | # "wagtail_editorjs/css/frontend.css",
108 | ]
109 |
110 | feature_mapping = EDITOR_JS_FEATURES.get_by_weight(
111 | self.features,
112 | )
113 |
114 | for feature in feature_mapping.values():
115 | _get_feature_scripts(feature, "get_js", list_obj=js)
116 | _get_feature_scripts(feature, "get_css", list_obj=css)
117 |
118 | js.extend([
119 | "wagtail_editorjs/js/editorjs-widget-controller.js",
120 | ])
121 |
122 | return widgets.Media(
123 | js=js,
124 | css={'all': css}
125 | )
126 |
127 |
128 |
129 | class EditorJSFormField(formfields.JSONField):
130 | def __init__(self, features: list[str] = None, tools_config: dict = None, *args, **kwargs):
131 | self.features = get_features(features)
132 | self.tools_config = tools_config or {}
133 | super().__init__(*args, **kwargs)
134 |
135 | @cached_property
136 | def widget(self):
137 | return EditorJSWidget(
138 | features=self.features,
139 | tools_config=self.tools_config,
140 | )
141 |
142 | def to_python(self, value):
143 | value = super().to_python(value)
144 |
145 | if value is None:
146 | return value
147 |
148 | value = EDITOR_JS_FEATURES.to_python(
149 | self.features, value
150 | )
151 |
152 | return value
153 |
154 | def prepare_value(self, value):
155 | if value is None:
156 | return super().prepare_value(value)
157 |
158 | if isinstance(value, formfields.InvalidJSONInput):
159 | return value
160 |
161 | if not isinstance(value, dict):
162 | return value
163 |
164 | value = EDITOR_JS_FEATURES.value_for_form(
165 | self.features, value
166 | )
167 |
168 | return super().prepare_value(value)
169 |
170 | def validate(self, value) -> None:
171 | super().validate(value)
172 |
173 | if value is None and self.required:
174 | raise forms.ValidationError("This field is required")
175 |
176 | if value:
177 | if not isinstance(value, dict):
178 | raise forms.ValidationError("Invalid EditorJS JSON object, expected a dictionary")
179 |
180 | if "time" not in value:
181 | raise forms.ValidationError("Invalid EditorJS JSON object, missing time")
182 |
183 | if "version" not in value:
184 | raise forms.ValidationError("Invalid EditorJS JSON object, missing version")
185 |
186 | time = value["time"] # 1713272305659
187 | if not isinstance(time, (int, float)):
188 | raise forms.ValidationError("Invalid EditorJS JSON object, time is not an integer")
189 |
190 | time_invalid = "Invalid EditorJS JSON object, time is invalid"
191 | try:
192 | time = datetime.fromtimestamp(time / 1000)
193 | except:
194 | raise forms.ValidationError(time_invalid)
195 |
196 | if time is None:
197 | raise forms.ValidationError(time_invalid)
198 |
199 | if value and self.required:
200 | if "blocks" not in value:
201 | raise forms.ValidationError("Invalid JSON object")
202 |
203 | if not value["blocks"]:
204 | raise forms.ValidationError("This field is required")
205 |
206 | EDITOR_JS_FEATURES.validate_for_tools(
207 | self.features, value
208 | )
209 |
210 |
211 |
212 |
--------------------------------------------------------------------------------
/wagtail_editorjs/static/wagtail_editorjs/vendor/editorjs/tools/underline.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Skipped minification because the original files appears to be already minified.
3 | * Original file: /npm/@editorjs/underline@1.1.0/dist/bundle.js
4 | *
5 | * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
6 | */
7 | !function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.Underline=t():e.Underline=t()}(window,(function(){return function(e){var t={};function n(r){if(t[r])return t[r].exports;var o=t[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,n),o.l=!0,o.exports}return n.m=e,n.c=t,n.d=function(e,t,r){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:r})},n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(n.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)n.d(r,o,function(t){return e[t]}.bind(null,o));return r},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="/",n(n.s=4)}([function(e,t,n){var r=n(1),o=n(2);"string"==typeof(o=o.__esModule?o.default:o)&&(o=[[e.i,o,""]]);var i={insert:"head",singleton:!1};r(o,i);e.exports=o.locals||{}},function(e,t,n){"use strict";var r,o=function(){return void 0===r&&(r=Boolean(window&&document&&document.all&&!window.atob)),r},i=function(){var e={};return function(t){if(void 0===e[t]){var n=document.querySelector(t);if(window.HTMLIFrameElement&&n instanceof window.HTMLIFrameElement)try{n=n.contentDocument.head}catch(e){n=null}e[t]=n}return e[t]}}(),a=[];function u(e){for(var t=-1,n=0;n'}}])&&o(t.prototype,n),r&&o(t,r),Object.defineProperty(t,"prototype",{writable:!1}),e}()}]).default}));
--------------------------------------------------------------------------------