integrate tiptap text editor

This commit is contained in:
bkfox 2024-04-30 18:29:34 +02:00
parent 55123c386d
commit 93fba2d46d
21 changed files with 327 additions and 149 deletions

View File

@ -2,6 +2,7 @@ import os
import inspect import inspect
from bleach import sanitizer
from django.conf import settings as d_settings from django.conf import settings as d_settings
@ -179,5 +180,10 @@ class Settings(BaseSettings):
ALLOW_COMMENTS = True ALLOW_COMMENTS = True
"""Allow comments.""" """Allow comments."""
# ---- bleach
ALLOWED_TAGS = [*sanitizer.ALLOWED_TAGS, "br", "p", "h3", "h4", "h5"]
ALLOWED_ATTRIBUTES = sanitizer.ALLOWED_ATTRIBUTES
ALLOWED_PROTOCOLS = sanitizer.ALLOWED_PROTOCOLS
settings = Settings("AIRCOX") settings = Settings("AIRCOX")

View File

@ -13,6 +13,7 @@ from django.utils.translation import gettext_lazy as _
from filer.fields.image import FilerImageField from filer.fields.image import FilerImageField
from model_utils.managers import InheritanceQuerySet from model_utils.managers import InheritanceQuerySet
from ..conf import settings
from .station import Station from .station import Station
__all__ = ( __all__ = (
@ -120,6 +121,14 @@ class BasePage(Renderable, models.Model):
return "{}".format(self.title or self.pk) return "{}".format(self.title or self.pk)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.content:
self.content = bleach.clean(
self.content,
tags=settings.ALLOWED_TAGS,
attributes=settings.ALLOWED_ATTRIBUTES,
protocols=settings.ALLOWED_PROTOCOLS,
)
if not self.slug: if not self.slug:
self.slug = slugify(self.title)[:100] self.slug = slugify(self.title)[:100]
count = Page.objects.filter(slug__startswith=self.slug).count() count = Page.objects.filter(slug__startswith=self.slug).count()
@ -165,17 +174,6 @@ class BasePage(Renderable, models.Model):
headline[-1] += suffix headline[-1] += suffix
return mark_safe(" ".join(headline)) return mark_safe(" ".join(headline))
_url_re = re.compile(
"((http|https)\:\/\/)?[a-zA-Z0-9\.\/\?\:@\-_=#]+\.([a-zA-Z]){2,6}([a-zA-Z0-9\.\&\/\?\:@\-_=#])*"
)
@cached_property
def display_content(self):
if "<p>" in self.content:
return self.content
content = self._url_re.sub(r'<a href="\1" target="new">\1</a>', self.content)
return content.replace("\n\n", "\n").replace("\n", "<br>")
@classmethod @classmethod
def get_init_kwargs_from(cls, page, **kwargs): def get_init_kwargs_from(cls, page, **kwargs):
kwargs.setdefault("cover", page.cover) kwargs.setdefault("cover", page.cover)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -136,9 +136,9 @@ Usefull context:
{% block content-container %} {% block content-container %}
{% if page and page.content %} {% if page and page.content %}
<section class="container content page-content"> <section class="container no-reset content page-content">
{% block content %} {% block content %}
{{ page.display_content|safe }} {{ page.content|safe }}
{% endblock %} {% endblock %}
</section> </section>
{% endif %} {% endif %}

View File

@ -18,6 +18,12 @@ aircox.labels = {% inline_labels %}
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block title-container %}
{{ block.super }}
{% block page-actions %}
{% include "aircox/widgets/page_actions.html" %}
{% endblock %}
{% endblock %}
{% block content-container %} {% block content-container %}
<a-select-file ref="cover-select" <a-select-file ref="cover-select"
@ -102,8 +108,11 @@ aircox.labels = {% inline_labels %}
<div> <div>
<div class="field {% if field.name != "content" %}is-horizontal{% endif %}"> <div class="field {% if field.name != "content" %}is-horizontal{% endif %}">
<label class="label">{{ field.label }}</label> <label class="label">{{ field.label }}</label>
<div class="control clear-unset"> <div class="control clear-unset no-reset">
<a-editor name="{{ field.name }}" initial="{{ field.value }}"/>
{% comment %}
<textarea name="{{ field.name }}" class="is-fullwidth height-25">{{ field.value|default:""|striptags|safe }}</textarea> <textarea name="{{ field.name }}" class="is-fullwidth height-25">{{ field.value|default:""|striptags|safe }}</textarea>
{% endcomment %}
</div> </div>
<p class="help">{{ field.help_text }}</p> <p class="help">{{ field.help_text }}</p>
</div> </div>

View File

@ -2,24 +2,24 @@
{% block user-actions-container %} {% block user-actions-container %}
{% if user.is_authenticated %} {% if user.is_authenticated %}
{{ object.get_status_display }} {{ object.get_status_display|capfirst }}
{% if object.pub_date %} {% if object.pub_date %}
({{ object.pub_date|date:"d/m/Y H:i" }}) ({{ object.pub_date|date:"d/m/Y H:i" }})
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if user.is_authenticated and can_edit %} {% if user.is_authenticated %}
{% with request.resolver_match.view_name as view_name %} {% with request.resolver_match.view_name as view_name %}
&nbsp; &nbsp;
{% if "-edit" in view_name %} {% if request.path != object.get_absolute_url %}
<a href="{% url view_name|detail_view page.slug %}" target="_self" title="{% translate 'View' %} {{ page }}"> <a href="{% url view_name|detail_view page.slug %}" target="_self" title="{% translate 'View' %} {{ page }}">
<span class="icon"> <span class="icon">
<i class="fa-regular fa-eye"></i> <i class="fa-regular fa-eye"></i>
</span> </span>
<span>{% translate 'View' %} </span> <span>{% translate 'View' %} </span>
</a> </a>
{% else %} {% elif can_edit %}
<a href="{% url view_name|edit_view page.pk %}" target="_self" title="{% translate 'Edit' %} {{ page }}"> <a href="{% url view_name|edit_view page.pk %}" target="_self" title="{% translate 'Edit' %} {{ page }}">
<span class="icon"> <span class="icon">
<i class="fa-solid fa-pencil"></i> <i class="fa-solid fa-pencil"></i>

View File

@ -55,6 +55,9 @@ class EpisodeUpdateView(UserPassesTestMixin, VueFormDataMixin, PageUpdateView):
form_class = forms.EpisodeForm form_class = forms.EpisodeForm
template_name = "aircox/episode_form.html" template_name = "aircox/episode_form.html"
def can_edit(self, obj):
return self.test_func()
def test_func(self): def test_func(self):
obj = self.get_object() obj = self.get_object()
return permissions.program.can(self.request.user, "update", obj) return permissions.program.can(self.request.user, "update", obj)

View File

@ -33,6 +33,17 @@ def attach(cls):
return cls return cls
class CanEditMixin:
"""Add context 'can_edit' set to True when object is editable by user."""
def can_edit(self, object):
"""Return True if user can edit current page."""
return False
def get_context_data(self, **kwargs):
return super().get_context_data(can_edit=self.can_edit(self.object), **kwargs)
class BasePageMixin: class BasePageMixin:
category = None category = None
@ -156,16 +167,12 @@ class PageListView(FiltersMixin, BasePageListView):
return super().get_context_data(**kwargs) return super().get_context_data(**kwargs)
class PageDetailView(BasePageDetailView): class PageDetailView(CanEditMixin, BasePageDetailView):
"""Base view class for pages.""" """Base view class for pages."""
template_name = None template_name = None
context_object_name = "page" context_object_name = "page"
def can_edit(self, object):
"""Return True if user can edit current page."""
return False
def get_template_names(self): def get_template_names(self):
return super().get_template_names() + ["aircox/page_detail.html"] return super().get_template_names() + ["aircox/page_detail.html"]
@ -185,7 +192,6 @@ class PageDetailView(BasePageDetailView):
if related: if related:
related = related[: self.related_count] related = related[: self.related_count]
kwargs["related_objects"] = related kwargs["related_objects"] = related
kwargs["can_edit"] = self.can_edit(self.object)
return super().get_context_data(**kwargs) return super().get_context_data(**kwargs)
def get_comment_form(self): def get_comment_form(self):
@ -210,7 +216,7 @@ class PageDetailView(BasePageDetailView):
return self.get(request, *args, **kwargs) return self.get(request, *args, **kwargs)
class PageCreateView(BaseView, CreateView): class PageCreateView(CanEditMixin, BaseView, CreateView):
def get_page(self): def get_page(self):
return self.object return self.object
@ -218,7 +224,7 @@ class PageCreateView(BaseView, CreateView):
return self.request.path return self.request.path
class PageUpdateView(BaseView, UpdateView): class PageUpdateView(CanEditMixin, BaseView, UpdateView):
def get_page(self): def get_page(self):
return self.object return self.object

View File

@ -71,6 +71,9 @@ class ProgramCreateView(PermissionRequiredMixin, ProgramEditMixin, page.PageCrea
class ProgramUpdateView(UserPassesTestMixin, ProgramEditMixin, page.PageUpdateView): class ProgramUpdateView(UserPassesTestMixin, ProgramEditMixin, page.PageUpdateView):
def can_edit(self, obj):
return self.test_func()
def test_func(self): def test_func(self):
obj = self.get_object() obj = self.get_object()
return permissions.program.can(self.request.user, "update", obj) return permissions.program.can(self.request.user, "update", obj)

View File

@ -20,6 +20,11 @@
"vue": "^3.4.21" "vue": "^3.4.21"
}, },
"devDependencies": { "devDependencies": {
"@tiptap/extension-link": "^2.3.0",
"@tiptap/extension-underline": "^2.3.0",
"@tiptap/pm": "^2.3.0",
"@tiptap/starter-kit": "^2.3.0",
"@tiptap/vue-3": "^2.3.0",
"@vitejs/plugin-vue": "^5.0.4", "@vitejs/plugin-vue": "^5.0.4",
"bulma": "^0.9.4", "bulma": "^0.9.4",
"eslint": "^7.32.0", "eslint": "^7.32.0",

View File

@ -0,0 +1,132 @@
<template>
<input ref="input" type="hidden" :name="name" :value="value"/>
<div class="">
<template v-for="group, index in menu" :key="index">
<div class="button-group d-inline-block mr-3">
<template v-for="info, index in group" :key="index">
<button type="button" class="button square smaller" :title="info.label" @click="edit(info.action, ...(info.args || []))">
<span class="icon"><i :class="info.icon"/></span>
</button>
</template>
</div>
</template>
<div class="button-group d-inline-block">
<div class="dropdown is-hoverable">
<div class="dropdown-trigger">
<button type="button" class="button square smaller">
<span class="icon"><i class="fa fa-link"/></span>
</button>
</div>
<div class="dropdown-menu" style="min-width: 20rem; margin-top: -0.2rem;">
<div class="dropdown-content p-3">
<div class="field">
<label class="label">Lien</label>
<div class="control">
<input ref="link-url" type="text" class="input" placeholder="lien"/>
</div>
</div>
<div class="has-text-right">
<button type="button" class="button secondary"
@click="edit('setLink', {href:$refs['link-url'].value})">
Ajouter le lien
</button>
</div>
</div>
</div>
</div>
<button type="button" class="button square smaller" title="Remove link" @click="edit('unsetLink')">
<span class="icon"><i class="fa fa-link-slash"/></span>
</button>
</div>
</div>
<editor-content class="editor" v-if="editor" :editor="editor" />
</template>
<style>
.editor .tiptap {
border: 1px black solid;
padding: 0.3em;
}
.editor .tiptap ul, .editor .tiptap ol {
margin-left: 1.3em;
}
.editor .tiptap ul { list-style: disc }
</style>
<script>
import { Editor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import Underline from '@tiptap/extension-underline'
import Link from '@tiptap/extension-link'
export default {
components: {EditorContent},
props: {
config: {type: Object, default: (() => {})},
//! Input field name.
name: String,
//! Initial input value
initial: String,
},
data() {
return {
editor: null,
menu: [
[
{label: "Bold", icon: "fa fa-bold", action: "toggleBold" },
{label: "Italic", icon: "fa fa-italic", action: "toggleItalic" },
{label: "Underline", icon: "fa fa-underline", action: "toggleUnderline" },
{label: "Strike", icon: "fa fa-strikethrough", action: "toggleStrike" },
],[
{label: "List", icon: "fa fa-list", action: "toggleBulletList" },
{label: "Ordered List", icon: "fa fa-list-ol", action: "toggleOrderedList" },
],[
{label: "Heading 1", icon: "fa fa-h", action: "setHeading", args: [{level:3}] },
{label: "Heading 2", icon: "fa fa-h smaller", action: "toggleHeading", args: [{level:4}] },
// {label: "Heading 3", icon: "fa fa-h small", action: "toggleHeading", args: [{level:5}] },
],
]
}
},
computed: {
value() { return this.editor && this.editor.getHTML() },
},
methods: {
chain(action, ...args) {
let chain = this.editor.chain().focus()
return chain[action](...args)
},
edit(action, ...args) {
this.chain(action, ...args).run()
},
setLink() {
this.edit("setLink", {href: this.$refs['link-url']})
},
},
mounted() {
this.editor = new Editor({
content: this.initial || "",
injectCss: false,
extensions: [
StarterKit.configure({
heading: {
levels: [3, 4, 5]
}
}),
Underline,
Link.configure({autolink: true}),
],
})
},
beforeUnmount() {
this.editor.destroy()
},
}
</script>

View File

@ -6,6 +6,7 @@ import AStreamer from './AStreamer.vue'
import AFormSet from './AFormSet.vue' import AFormSet from './AFormSet.vue'
import ATrackListEditor from './ATrackListEditor.vue' import ATrackListEditor from './ATrackListEditor.vue'
import ASoundListEditor from './ASoundListEditor.vue' import ASoundListEditor from './ASoundListEditor.vue'
import AEditor from './AEditor.vue'
import AManyToManyEdit from "./AManyToManyEdit.vue" import AManyToManyEdit from "./AManyToManyEdit.vue"
@ -15,7 +16,7 @@ import base from "./index.js"
export const admin = { export const admin = {
...base, ...base,
AManyToManyEdit, AManyToManyEdit,
AFileUpload, ASelectFile, AFileUpload, ASelectFile, AEditor,
AFormSet, ATrackListEditor, ASoundListEditor, AFormSet, ATrackListEditor, ASoundListEditor,
AStatistics, AStreamer, AStatistics, AStreamer,
} }

View File

@ -3,7 +3,8 @@
:root { :root {
--title-1-sz: 1.6rem; --title-1-sz: 1.6rem;
--title-2-sz: 1.4rem; --title-2-sz: 1.4rem;
--title-3-sz: 1.2rem; --title-3-sz: 1.3rem;
--title-4-sz: 1.2rem;
--subtitle-1-sz: 1.6rem; --subtitle-1-sz: 1.6rem;
--subtitle-2-sz: 1.4rem; --subtitle-2-sz: 1.4rem;
--subtitle-3-sz: 1.2rem; --subtitle-3-sz: 1.2rem;
@ -96,6 +97,14 @@
} }
// ---- headings // ---- headings
.no-reset h1 { font-size: var(--title-1-sz); }
.no-reset h2 { font-size: var(--title-2-sz); }
.no-reset h3 { font-size: var(--title-3-sz); }
.no-reset h3 { font-size: var(--title-3-sz); }
.no-reset h4 { font-size: var(--title-4-sz); }
.no-reset h5 { font-size: var(--title-5-sz); }
.title, .header.preview .title { .title, .header.preview .title {
&.is-1 { font-size: var(--title-1-sz); } &.is-1 { font-size: var(--title-1-sz); }
&.is-2 { font-size: var(--title-2-sz); } &.is-2 { font-size: var(--title-2-sz); }
@ -215,6 +224,10 @@
&:last-child { border-right: 0px; } &:last-child { border-right: 0px; }
} }
} }
.button-group + .button-group {
border-left: 1px solid var(--text-color-light);
}
} }

View File

@ -138,6 +138,9 @@
.bg-secondary-light { background-color: var(--secondary-color-light); } .bg-secondary-light { background-color: var(--secondary-color-light); }
.bg-transparent { background-color: transparent; } .bg-transparent { background-color: transparent; }
.border { border: 1px solid var(--text-color); }
.border-main { border: 1px solid var(--main-color); }
.border-secondary { border: 1px solid var(--secondary-color); }
.border-bottom-main { border-bottom: 1px solid var(--main-color); } .border-bottom-main { border-bottom: 1px solid var(--main-color); }
.border-bottom-secondary { border-bottom: 1px solid var(--secondary-color); } .border-bottom-secondary { border-bottom: 1px solid var(--secondary-color); }

View File

@ -77,99 +77,6 @@ except Exception:
# -- django-taggit # -- django-taggit
TAGGIT_CASE_INSENSITIVE = True TAGGIT_CASE_INSENSITIVE = True
# -- django-CKEditor
CKEDITOR_CONFIGS = {
"default": {
"format_tags": "h1;h2;h3;p;pre",
# 'skin': 'office2013',
"toolbar_Custom": [
{
"name": "editing",
"items": [
"Undo",
"Redo",
"-",
"Find",
"Replace",
"-",
"Source",
],
},
{
"name": "basicstyles",
"items": [
"Bold",
"Italic",
"Underline",
"Strike",
"Subscript",
"Superscript",
"-",
"RemoveFormat",
],
},
{
"name": "paragraph",
"items": [
"NumberedList",
"BulletedList",
"-",
"Outdent",
"Indent",
"-",
"Blockquote",
"CreateDiv",
"-",
"JustifyLeft",
"JustifyCenter",
"JustifyRight",
"JustifyBlock",
"-",
],
},
"/",
{"name": "links", "items": ["Link", "Unlink", "Anchor"]},
{
"name": "insert",
"items": [
"Image",
"Table",
"HorizontalRule",
"SpecialChar",
"PageBreak",
"Iframe",
],
},
{
"name": "styles",
"items": ["Styles", "Format", "Font", "FontSize"],
},
{"name": "colors", "items": ["TextColor", "BGColor"]},
"/", # put this to force next toolbar on new line
],
"toolbar": "Custom",
"extraPlugins": ",".join(
[
"uploadimage",
"div",
"autolink",
"autoembed",
"embedsemantic",
"embed",
"iframe",
"iframedialog",
"autogrow",
"widget",
"lineutils",
"dialog",
"dialogui",
"elementspath",
]
),
},
}
CKEDITOR_UPLOAD_PATH = "uploads/"
# -- easy_thumbnails # -- easy_thumbnails
THUMBNAIL_PROCESSORS = ( THUMBNAIL_PROCESSORS = (
@ -190,8 +97,6 @@ INSTALLED_APPS = (
"rest_framework", "rest_framework",
"django_filters", "django_filters",
"content_editor", "content_editor",
"ckeditor",
"ckeditor_uploader",
"easy_thumbnails", "easy_thumbnails",
"filer", "filer",
"taggit", "taggit",

View File

@ -8,7 +8,6 @@ django-filer~=3.1
django-honeypot~=1.0 django-honeypot~=1.0
django-taggit~=3.0 django-taggit~=3.0
django-admin-sortable2~=2.1 django-admin-sortable2~=2.1
django-ckeditor~=6.4
bleach~=5.0 bleach~=5.0
easy-thumbnails~=2.8 easy-thumbnails~=2.8
tzlocal~=4.2 tzlocal~=4.2