Skip to content

Commit a27e8d4

Browse files
authored
feat(widgets): Add Tiptap rich text editor (#214)
* feat(widgets): Add Tiptap rich text editor * feat(richtext): Add reusable editor controls * feat(richtext): Support disabling editor actions * feat(richtext): Support disabling toolbar actions * feat(widgets): Make rich text toolbar configurable * fix(richtext): Honor disabled actions in templates * fix(richtext): Use background color icon * fix(richtext): Sanitize submitted HTML * fix(widgets): support rich text without auto-generated IDs * fix(richtext): Update callbacks on reused controllers * fix(richtext): Refresh filer images when items update * fix(richtext): prevent built-in image picker conflicts * fix(richtext): Scope media items to widget root * fix(translations): Scope rich text item lookup to root * fix(audit): Handle lazy object representations safely * fix(richtext): prevent duplicate bootstrap listeners * chore(release): Bump version to 2.3.9
1 parent fe95e25 commit a27e8d4

30 files changed

Lines changed: 2011 additions & 27 deletions

File tree

package.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,15 @@
1515
"@sentry/browser": "^6.18.2",
1616
"@sentry/tracing": "^6.18.2",
1717
"@tailwindcss/line-clamp": "^0.3.1",
18+
"@tiptap/core": "^3.0.0",
19+
"@tiptap/extension-color": "^3.0.0",
20+
"@tiptap/extension-link": "^3.0.0",
21+
"@tiptap/extension-table": "^3.0.0",
22+
"@tiptap/extension-text-align": "^3.0.0",
23+
"@tiptap/extension-text-style": "^3.0.0",
24+
"@tiptap/extension-underline": "^3.0.0",
25+
"@tiptap/pm": "^3.0.0",
26+
"@tiptap/starter-kit": "^3.0.0",
1827
"axios": "^0.18.0",
1928
"bootstrap": "^5.2.3",
2029
"chart.js": "^4.3.0",

poetry.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "django-smartbase-admin"
3-
version = "2.3.8"
3+
version = "2.3.9"
44
description = ""
55
authors = ["SmartBase <info@smartbase.sk>"]
66
readme = "README.md"
@@ -46,6 +46,8 @@ django-htmx = "^1.17.3"
4646
# JSON Schema validation server-side for SBAdminJsonEditorField
4747
jsonschema = "^4.10"
4848
lzstring = "^1.0.4"
49+
# HTML sanitization for rich-text submissions and MCP display values.
50+
nh3 = ">=0.2.15"
4951
# required for django-filer
5052
setuptools = "^67.0.0"
5153
# Optional MCP dependencies: only required when wiring the MCP toolset
@@ -60,16 +62,13 @@ django-mcp-server = {version = "^0.5.7", optional = true}
6062
# ``from mcp.server import FastMCP`` fails on a fresh install. Cap here
6163
# until upstream supports 2.x.
6264
mcp = {version = ">=1.8,<2", optional = true}
63-
# Allowlist HTML sanitizer for ``fetch_detail`` display values.
64-
nh3 = {version = ">=0.2.15", optional = true}
6565

6666
[tool.poetry.extras]
6767
mcp = [
6868
"django-oauth-toolkit",
6969
"djangorestframework",
7070
"django-mcp-server",
7171
"mcp",
72-
"nh3",
7372
]
7473

7574
[build-system]

src/django_smartbase_admin/admin/widgets.py

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import json
22
import sys
3+
from html.parser import HTMLParser
34

5+
import nh3
46
from ckeditor.widgets import CKEditorWidget
57
from ckeditor_uploader.widgets import CKEditorUploadingWidget
68
from django import forms
@@ -328,6 +330,249 @@ def __init__(self, form_field=None, attrs=None):
328330
super().__init__(form_field, attrs={"class": "input", **(attrs or {})})
329331

330332

333+
def _parse_richtext_image_id(value):
334+
if not value or not value.isascii() or not value.isdigit() or value.startswith("0"):
335+
return None
336+
try:
337+
return int(value)
338+
except ValueError:
339+
return None
340+
341+
342+
class _RichTextImageIdParser(HTMLParser):
343+
def __init__(self):
344+
super().__init__(convert_charrefs=True)
345+
self.image_ids = []
346+
347+
def handle_starttag(self, tag, attrs):
348+
if tag.casefold() != "img":
349+
return
350+
image_id = _parse_richtext_image_id(dict(attrs).get("data-filer-image-id"))
351+
if image_id is not None:
352+
self.image_ids.append(image_id)
353+
354+
355+
_RICH_TEXT_ALLOWED_ATTRIBUTES = {
356+
tag: set(attributes) for tag, attributes in nh3.ALLOWED_ATTRIBUTES.items()
357+
}
358+
_RICH_TEXT_ALLOWED_ATTRIBUTES["*"] = {"style"}
359+
_RICH_TEXT_ALLOWED_ATTRIBUTES.setdefault("img", set()).update(
360+
{"data-filer-image-id", "loading", "title"}
361+
)
362+
_RICH_TEXT_ALLOWED_ATTRIBUTES.setdefault("td", set()).add("colwidth")
363+
_RICH_TEXT_ALLOWED_ATTRIBUTES.setdefault("th", set()).add("colwidth")
364+
365+
366+
def _sanitize_rich_text(value):
367+
"""Sanitize submitted HTML while preserving the attributes Tiptap emits.
368+
369+
The nh3 defaults provide the security allowlist, but omit ``style``, filer image
370+
IDs, image metadata, and table ``colwidth``. Those attributes are added above,
371+
with CSS still restricted to the three properties supported by this editor.
372+
``link_rel`` is disabled because Tiptap does not emit ``rel`` without ``target``.
373+
"""
374+
if value is None or value == "":
375+
return value
376+
return nh3.clean(
377+
str(value),
378+
tags=nh3.ALLOWED_TAGS,
379+
attributes=_RICH_TEXT_ALLOWED_ATTRIBUTES,
380+
filter_style_properties={"color", "text-align", "width"},
381+
link_rel=None,
382+
)
383+
384+
385+
class SBAdminRichTextWidget(SBAdminTextareaWidget):
386+
template_name = "sb_admin/widgets/richtext.html"
387+
button_template = "sb_admin/widgets/includes/richtext/action_button.html"
388+
toolbar_actions = (
389+
{
390+
"name": "block",
391+
"template": "sb_admin/widgets/includes/richtext/block.html",
392+
},
393+
{"name": "bold", "label": _("Bold"), "icon": "Text-bold"},
394+
{"name": "italic", "label": _("Italic"), "icon": "Text-italic"},
395+
{
396+
"name": "underline",
397+
"label": _("Underline"),
398+
"icon": "Text-underline",
399+
},
400+
{
401+
"name": "color",
402+
"label": _("Text color"),
403+
"template": "sb_admin/widgets/includes/richtext/color.html",
404+
},
405+
{
406+
"name": "align-left",
407+
"label": _("Align left"),
408+
"icon": "Align-text-left",
409+
},
410+
{
411+
"name": "align-center",
412+
"label": _("Align center"),
413+
"icon": "Align-text-center",
414+
},
415+
{
416+
"name": "align-right",
417+
"label": _("Align right"),
418+
"icon": "Align-text-right",
419+
},
420+
{
421+
"name": "align-justify",
422+
"label": _("Justify"),
423+
"icon": "Align-text-both",
424+
},
425+
{
426+
"name": "link",
427+
"label": _("Link"),
428+
"icon": "Link",
429+
"dialog_template": "sb_admin/widgets/includes/richtext/link_dialog.html",
430+
},
431+
{
432+
"name": "image",
433+
"label": _("Image"),
434+
"icon": "Add-picture",
435+
"template": "sb_admin/widgets/includes/richtext/image.html",
436+
},
437+
{
438+
"name": "table",
439+
"label": _("Insert table"),
440+
"icon": "Insert-table",
441+
},
442+
{
443+
"name": "bullet-list",
444+
"label": _("Bulleted list"),
445+
"icon": "List-two",
446+
},
447+
{
448+
"name": "ordered-list",
449+
"label": _("Numbered list"),
450+
"icon": "List-numbers",
451+
},
452+
{"name": "outdent", "label": _("Outdent"), "icon": "Indent-left"},
453+
{"name": "indent", "label": _("Indent"), "icon": "Indent-right"},
454+
{
455+
"name": "clear",
456+
"label": _("Clear formatting"),
457+
"icon": "Clear-format",
458+
},
459+
{"name": "source", "label": _("HTML source"), "icon": "Code"},
460+
{
461+
"name": "table-menu",
462+
"label": _("Table options"),
463+
"requires": "table",
464+
"template": "sb_admin/widgets/includes/richtext/table_menu.html",
465+
},
466+
)
467+
468+
class Media:
469+
extend = False
470+
js = [
471+
"sb_admin/dist/media_picker.js",
472+
"sb_admin/dist/richtext.js",
473+
]
474+
475+
def __init__(
476+
self,
477+
form_field=None,
478+
attrs=None,
479+
*,
480+
is_public=True,
481+
disabled_actions=(),
482+
):
483+
self.form = None
484+
self.field_name = None
485+
self.view = None
486+
self.request = None
487+
self.is_public = is_public
488+
self.disabled_actions = tuple(disabled_actions)
489+
attrs = attrs or {}
490+
super().__init__(
491+
form_field=form_field,
492+
attrs={
493+
"class": (
494+
f"input sbadmin-richtext__source hidden "
495+
f"{attrs.get('class') or ''}"
496+
).strip(),
497+
"rows": 8,
498+
**{key: value for key, value in attrs.items() if key != "class"},
499+
},
500+
)
501+
502+
def init_widget_dynamic(self, form, form_field, field_name, view, request):
503+
super().init_widget_dynamic(form, form_field, field_name, view, request)
504+
self.form = form
505+
self.field_name = field_name
506+
self.view = view
507+
self.request = request
508+
509+
def get_request(self):
510+
return self.request or SBAdminThreadLocalService.get_request()
511+
512+
def value_from_datadict(self, data, files, name):
513+
return _sanitize_rich_text(super().value_from_datadict(data, files, name))
514+
515+
@staticmethod
516+
def image_ids(value):
517+
parser = _RichTextImageIdParser()
518+
parser.feed(str(value or ""))
519+
parser.close()
520+
return tuple(dict.fromkeys(parser.image_ids))
521+
522+
def image_items(self, value):
523+
image_ids = self.image_ids(value)
524+
request = self.get_request()
525+
if not image_ids or request is None or not hasattr(request, "request_data"):
526+
return {}
527+
queryset = FilerMediaPickerService.accessible_item_queryset(
528+
request,
529+
MEDIA_PICKER_TYPE_IMAGE,
530+
).filter(pk__in=image_ids)
531+
if self.is_public is not None:
532+
queryset = queryset.filter(is_public=self.is_public)
533+
return {
534+
str(image.pk): {
535+
"label": item["name"],
536+
"thumbnail_url": item["thumbnail_url"],
537+
"original_url": item["original_url"],
538+
}
539+
for image in queryset
540+
for item in (FilerMediaPickerService.item_data(image),)
541+
}
542+
543+
def get_toolbar_actions(self):
544+
disabled_actions = set(self.disabled_actions)
545+
actions = []
546+
for definition in self.toolbar_actions:
547+
if definition["name"] in disabled_actions:
548+
continue
549+
if definition.get("requires") in disabled_actions:
550+
continue
551+
action = {"template": self.button_template, **definition}
552+
if action["name"] == "image":
553+
action["media_picker_url"] = (
554+
f"{reverse('sb_admin:media_picker')}?picker_type=image"
555+
f"&is_public={str(self.is_public).lower()}"
556+
)
557+
actions.append(action)
558+
return tuple(actions)
559+
560+
def get_context(self, name, value, attrs):
561+
context = super().get_context(name, value, attrs)
562+
widget = context["widget"]
563+
widget["items"] = self.image_items(value)
564+
widget["is_readonly"] = bool(
565+
widget["attrs"].get("readonly") or widget["attrs"].get("disabled")
566+
)
567+
# The textarea is hidden behind Tiptap, so native required validation cannot
568+
# focus it. Django remains responsible for required-field validation and the
569+
# surrounding form templates render the resulting bound-field errors.
570+
widget["attrs"].pop("required", None)
571+
widget["disabled_actions"] = self.disabled_actions
572+
widget["toolbar_actions"] = self.get_toolbar_actions()
573+
return context
574+
575+
331576
class SBAdminEmailInputWidget(
332577
SBAdminInputAffixMixin, SBAdminBaseWidget, forms.EmailInput
333578
):

0 commit comments

Comments
 (0)