From ade878a0cd2707ee38371b1a2ccde2f59a535aaf Mon Sep 17 00:00:00 2001 From: rucki Date: Thu, 27 Aug 2026 10:57:14 +0200 Subject: [PATCH] Add rich text formatting toolbar --- core/templates/core/_edit_form.html | 1 + core/templates/core/_format_toolbar.html | 16 ++++++++++ core/templates/core/base.html | 7 +++-- core/templates/core/index.html | 35 +++++++++++++++++++++ core/templates/core/item_editor.html | 1 + core/templates/core/journal.html | 1 + core/templates/core/new_item.html | 1 + core/templatetags/markdown_extras.py | 2 +- core/tests.py | 13 ++++++++ locale/de/LC_MESSAGES/django.po | 40 ++++++++++++++++++++++++ locale/en/LC_MESSAGES/django.po | 40 ++++++++++++++++++++++++ 11 files changed, 153 insertions(+), 4 deletions(-) create mode 100644 core/templates/core/_format_toolbar.html diff --git a/core/templates/core/_edit_form.html b/core/templates/core/_edit_form.html index 6e7525a..f251d00 100644 --- a/core/templates/core/_edit_form.html +++ b/core/templates/core/_edit_form.html @@ -11,6 +11,7 @@ + {% include 'core/_format_toolbar.html' %}
diff --git a/core/templates/core/_format_toolbar.html b/core/templates/core/_format_toolbar.html new file mode 100644 index 0000000..c27b63d --- /dev/null +++ b/core/templates/core/_format_toolbar.html @@ -0,0 +1,16 @@ +{% load i18n %} +
+ +
+ + + + + + + + + +
+ {% trans 'Select text, then format it' %} +
diff --git a/core/templates/core/base.html b/core/templates/core/base.html index 878d4b6..de6fc5f 100644 --- a/core/templates/core/base.html +++ b/core/templates/core/base.html @@ -17,7 +17,7 @@ @@ -28,7 +28,7 @@ document.body.addEventListener('htmx:configRequest', e => {e.detail.headers['X-CSRFToken']='{{ csrf_token }}'}); window.quickComposer = window.quickComposer || function(tags, itemId=null){ return { - open:false,showTags:false,text:'',active:0,query:'',queryStart:0,currentEl:null,preview:null,pendingFiles:[],linkSuggestions:[],attachmentSuggestions:[],itemId:itemId,tags:tags||[], + open:false,showTags:false,showFormatting:false,text:'',active:0,query:'',queryStart:0,currentEl:null,preview:null,pendingFiles:[],linkSuggestions:[],attachmentSuggestions:[],itemId:itemId,tags:tags||[], init(){document.body.addEventListener('tagsChanged',()=>{fetch('/api/tags/').then(r=>r.json()).then(d=>{this.tags=d.tags||[]})})}, commands:[{token:'/todo',label:'{% trans "Create task" %}',group:'{% trans "Type" %}'},{token:'/note',label:'{% trans "Create note" %}',group:'{% trans "Type" %}'},{token:'/link',label:'{% trans "Save link" %}',group:'{% trans "Type" %}'},{token:'/journal',label:'{% trans "Journal entry" %}',group:'{% trans "Type" %}'},{token:'/public',label:'{% trans "public" %}',group:'{% trans "Visibility" %}'},{token:'/private',label:'{% trans "private" %}',group:'{% trans "Visibility" %}'},{token:'/datum',label:'{% trans "Due date (DD.MM.YY)" %}',group:'{% trans "Date" %}'},{token:'/morgen',label:'{% trans "Tomorrow" %}',group:'{% trans "Date" %}'},{token:'/übermorgen',label:'{% trans "Day after tomorrow" %}',group:'{% trans "Date" %}'},{token:'/nächstewoche',label:'{% trans "Next week" %}',group:'{% trans "Date" %}'},{token:'/code',label:'{% trans "Insert Markdown code block" %}',group:'{% trans "Formatting" %}'},{token:'[[',label:'{% trans "Search internal task/note" %}',group:'{% trans "References" %}'},{token:'![[',label:'{% trans "Insert image from attachments" %}',group:'{% trans "References" %}'}], get filtered(){let q=this.query.toLowerCase();let selectedType=(this.text.match(/^\/(todo|note|link|journal)(?=\s)/i)||[])[1]?.toLowerCase();let typeTokens=['/todo','/note','/link','/journal'];let dateTokens=['/datum','/morgen','/übermorgen','/nächstewoche'];let commands=this.commands.filter(c=>{if(typeTokens.includes(c.token))return !selectedType&&this.queryStart===0;if(this.queryStart===0&&!selectedType)return false;if(selectedType&&selectedType!=='todo'&&dateTokens.includes(c.token))return false;return true});let list=q.startsWith('#')?this.tags.filter(t=>('#'+t).toLowerCase().startsWith(q)).map(t=>({token:'#'+t,label:'Tag',group:'{% trans "Tags" %}'})):q.startsWith('![[')?(this.itemId?this.attachmentSuggestions.map(a=>({token:`![[file:${a.id}]]`,label:`{% trans "Image:" %} ${a.name}`,group:'{% trans "References" %}'})):this.pendingFiles.map(f=>({token:`![[paste:${f.name}]]`,label:`Bild: ${f.name}`,group:'{% trans "References" %}'}))):q.startsWith('[[')?this.linkSuggestions.map(i=>({token:`[[${i.id}]]`,label:`${i.kind}: ${i.label}`,group:'{% trans "References" %}'})):commands.filter(c=>!q||c.token.toLowerCase().startsWith(q));return list.length?list:[{token:this.query,label:'neu',group:''}]}, @@ -37,8 +37,9 @@ window.quickComposer = window.quickComposer || function(tags, itemId=null){ handlePaste(e){let item=[...(e.clipboardData?.items||[])].find(i=>i.kind==='file'&&i.type.startsWith('image/'));if(!item||!this.$refs.fileInput)return;let file=item.getAsFile();let ext=(file.type.split('/')[1]||'png').replace('jpeg','jpg');let named=new File([file],`paste-${new Date().toISOString().replace(/[:.]/g,'-')}.${ext}`,{type:file.type});let dt=new DataTransfer();[...(this.$refs.fileInput.files||[])].forEach(f=>dt.items.add(f));dt.items.add(named);this.$refs.fileInput.files=dt.files;this.pendingFiles=[...dt.files].filter(f=>f.type.startsWith('image/'));this.preview=URL.createObjectURL(named)}, insertImageToken(){if(this.pendingFiles.length)this.insert(`![[paste:${this.pendingFiles[this.pendingFiles.length-1].name}]]`)}, previewFile(e){this.pendingFiles=[...e.target.files].filter(f=>f.type.startsWith('image/'));let file=this.pendingFiles[0];this.preview=file?URL.createObjectURL(file):null}, - onKeydown(e){this.currentEl=e.target;if((e.ctrlKey||e.metaKey)&&e.key==='Enter'){e.preventDefault();this.open=false;e.target.form.requestSubmit();return}if(this.open&&e.key==='ArrowDown'){e.preventDefault();this.active=(this.active+1)%this.filtered.length;return}if(this.open&&e.key==='ArrowUp'){e.preventDefault();this.active=(this.active-1+this.filtered.length)%this.filtered.length;return}if(this.open&&(e.key==='Enter'||e.key==='Tab')&&!e.shiftKey){e.preventDefault();this.insert(this.filtered[this.active].token);return}if(e.key==='Escape'){this.open=false;return}if(e.key===' '){this.open=false;return}setTimeout(()=>this.update(e.target),0)}, + onKeydown(e){this.currentEl=e.target;let shortcut=(e.ctrlKey||e.metaKey)&&{b:'bold',i:'italic',u:'underline'}[e.key.toLowerCase()];if(shortcut){e.preventDefault();this.formatSelection(shortcut);return}if((e.ctrlKey||e.metaKey)&&e.key==='Enter'){e.preventDefault();this.open=false;e.target.form.requestSubmit();return}if(this.open&&e.key==='ArrowDown'){e.preventDefault();this.active=(this.active+1)%this.filtered.length;return}if(this.open&&e.key==='ArrowUp'){e.preventDefault();this.active=(this.active-1+this.filtered.length)%this.filtered.length;return}if(this.open&&(e.key==='Enter'||e.key==='Tab')&&!e.shiftKey){e.preventDefault();this.insert(this.filtered[this.active].token);return}if(e.key==='Escape'){this.open=false;return}if(e.key===' '){this.open=false;return}setTimeout(()=>this.update(e.target),0)}, update(el){this.currentEl=el;if(this.isContentField(el))this.text=el.value;let before=el.value.slice(0,el.selectionStart);let fragment=(before.match(/(^|\s)([^\s]*)$/)||['','',''])[2];this.query=fragment;this.queryStart=el.selectionStart-fragment.length;this.active=0;this.open=fragment.startsWith('/')||fragment.startsWith('#')||fragment.startsWith('[[')||fragment.startsWith('![[');if(fragment.startsWith('![[')&&this.itemId){fetch(`/api/attachment-suggestions/?item=${this.itemId}`).then(r=>r.json()).then(d=>{this.attachmentSuggestions=d.attachments||[]})}else if(fragment.startsWith('[[')){fetch(`/api/item-suggestions/?q=${encodeURIComponent(fragment.slice(2))}`).then(r=>r.json()).then(d=>{this.linkSuggestions=d.items||[]})}}, + formatSelection(kind){let el=this.currentEl&&this.currentEl.matches('textarea')?this.currentEl:this.$el.querySelector('textarea[name="content"],textarea');if(!el)return;let start=el.selectionStart??0,end=el.selectionEnd??start;let selected=el.value.slice(start,end);let wrappers={bold:['**','**','Text'],italic:['*','*','Text'],underline:['','','Text'],code:['`','`','Code']};let value,newStart,newEnd;if(wrappers[kind]){let [before,after,placeholder]=wrappers[kind];let inner=selected||placeholder;value=el.value.slice(0,start)+before+inner+after+el.value.slice(end);newStart=start+before.length;newEnd=newStart+inner.length}else{let prefixes={heading:'## ',quote:'> ',list:'- ',numbered:'1. '};let prefix=prefixes[kind]||'';let inner=selected||'{% trans "Text" %}';let formatted=inner.split('\n').map(line=>prefix+line).join('\n');value=el.value.slice(0,start)+formatted+el.value.slice(end);newStart=start;newEnd=start+formatted.length}el.value=value;if(this.isContentField(el))this.text=value;this.$nextTick(()=>{el.focus();el.setSelectionRange(newStart,newEnd)})}, addToken(token){let el=this.currentEl||this.$el.querySelector('textarea');if(!el)return;let pos=el.selectionStart??el.value.length;let before=el.value.slice(0,pos);let after=el.value.slice(pos);let prefix=before&& !before.endsWith(' ') && !before.endsWith('\n')?' ':'';let value=before+prefix+token+' '+after;if(this.isContentField(el))this.text=value;el.value=value;this.showTags=false;this.$nextTick(()=>{let p=before.length+prefix.length+token.length+1;el.focus();el.setSelectionRange(p,p)})}, insert(token){let el=this.currentEl||this.$el.querySelector('textarea');if(!el)return;let isCode=token==='/code';if(isCode)token='```\n\n```';let pos=el.selectionStart;let before=el.value.slice(0,pos);let after=el.value.slice(pos);let match=before.match(/(^|\s)([^\s]*)$/);let start=match?pos-match[2].length:pos;let needsPrefix=start>0&&el.value[start-1]!=='\n'&&el.value[start-1]!==' ';let prefix=isCode?(start>0&&el.value[start-1]!=='\n'?'\n':''):(needsPrefix?' ':'');let suffix=isCode?'': ' ';let value=el.value.slice(0,start)+prefix+token+suffix+after;if(this.isContentField(el))this.text=value;el.value=value;this.open=false;this.$nextTick(()=>{let p=isCode?start+prefix.length+4:start+prefix.length+token.length+suffix.length;el.focus();el.setSelectionRange(p,p)})} } diff --git a/core/templates/core/index.html b/core/templates/core/index.html index 9d05c62..960035b 100644 --- a/core/templates/core/index.html +++ b/core/templates/core/index.html @@ -119,6 +119,7 @@
{% trans "Add tags" %}
{% for tag in tags %}{% endfor %}
{{ form.content }} + {% include 'core/_format_toolbar.html' %}
@@ -139,6 +140,7 @@ function quickComposer(tags, itemId=null){ return { open:false, + showFormatting:false, text:'', active:0, query:'', @@ -215,6 +217,12 @@ function quickComposer(tags, itemId=null){ }, onKeydown(e){ this.currentEl=e.target; + let shortcut=(e.ctrlKey||e.metaKey)&&{b:'bold',i:'italic',u:'underline'}[e.key.toLowerCase()]; + if(shortcut){ + e.preventDefault(); + this.formatSelection(shortcut); + return; + } if((e.ctrlKey||e.metaKey)&&e.key==='Enter'){ e.preventDefault(); this.open=false; @@ -262,6 +270,33 @@ function quickComposer(tags, itemId=null){ fetch(`{% url "api_item_suggestions" %}?q=${encodeURIComponent(q)}`).then(r=>r.json()).then(data=>{this.linkSuggestions=data.items||[]}); } }, + formatSelection(kind){ + let el=this.currentEl&&this.currentEl.matches('textarea')?this.currentEl:this.$el.querySelector('textarea[name="content"],textarea'); + if(!el) return; + let start=el.selectionStart??0; + let end=el.selectionEnd??start; + let selected=el.value.slice(start,end); + let wrappers={bold:['**','**','Text'],italic:['*','*','Text'],underline:['','','Text'],code:['`','`','Code']}; + let value,newStart,newEnd; + if(wrappers[kind]){ + let [before,after,placeholder]=wrappers[kind]; + let inner=selected||placeholder; + value=el.value.slice(0,start)+before+inner+after+el.value.slice(end); + newStart=start+before.length; + newEnd=newStart+inner.length; + }else{ + let prefixes={heading:'## ',quote:'> ',list:'- ',numbered:'1. '}; + let prefix=prefixes[kind]||''; + let inner=selected||'{% trans "Text" %}'; + let formatted=inner.split('\n').map(line=>prefix+line).join('\n'); + value=el.value.slice(0,start)+formatted+el.value.slice(end); + newStart=start; + newEnd=start+formatted.length; + } + el.value=value; + if(this.isContentField(el)) this.text=value; + this.$nextTick(()=>{el.focus();el.setSelectionRange(newStart,newEnd)}); + }, addToken(token){ let el=this.currentEl || document.getElementById('id_content'); if(!el) return; diff --git a/core/templates/core/item_editor.html b/core/templates/core/item_editor.html index c76ea35..6356553 100644 --- a/core/templates/core/item_editor.html +++ b/core/templates/core/item_editor.html @@ -17,6 +17,7 @@ + {% include 'core/_format_toolbar.html' %}
diff --git a/core/templates/core/journal.html b/core/templates/core/journal.html index 815df8e..3781fd2 100644 --- a/core/templates/core/journal.html +++ b/core/templates/core/journal.html @@ -28,6 +28,7 @@
{% trans "Add tags" %}
{% for tag in tags %}{% endfor %}
{{ form.content }} + {% include 'core/_format_toolbar.html' %}
diff --git a/core/templates/core/new_item.html b/core/templates/core/new_item.html index 97c36d8..c1c6ae9 100644 --- a/core/templates/core/new_item.html +++ b/core/templates/core/new_item.html @@ -16,6 +16,7 @@ + {% include 'core/_format_toolbar.html' %}
diff --git a/core/templatetags/markdown_extras.py b/core/templatetags/markdown_extras.py index 0a2bd17..3a39a0e 100644 --- a/core/templatetags/markdown_extras.py +++ b/core/templatetags/markdown_extras.py @@ -8,7 +8,7 @@ register = template.Library() ALLOWED_TAGS = set(bleach.sanitizer.ALLOWED_TAGS) | { 'p', 'pre', 'code', 'blockquote', 'h1', 'h2', 'h3', 'h4', 'ul', 'ol', 'li', - 'strong', 'em', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr', 'br', 'img' + 'strong', 'em', 'u', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr', 'br', 'img' } ALLOWED_ATTRS = {'a': ['href', 'title', 'rel'], 'code': ['class'], 'img': ['src', 'alt', 'title', 'class']} FILE_RE = re.compile(r'!\[\[file:(\d+)\]\]') diff --git a/core/tests.py b/core/tests.py index ea0d7e3..6203952 100644 --- a/core/tests.py +++ b/core/tests.py @@ -104,6 +104,19 @@ class MarkdownHeadingTests(TestCase): self.assertIn('# comment', rendered) self.assertNotIn('\\# comment', rendered) + def test_underline_is_allowed(self): + self.assertIn('underlined', str(markdown('underlined'))) + + +class FormattingToolbarTests(TestCase): + def test_toolbar_and_shortcuts_are_available(self): + user = get_user_model().objects.create_user(username='formatting', password='secret') + self.client.force_login(user) + response = self.client.get(reverse('index')) + self.assertContains(response, 'class="format-toolbar') + self.assertContains(response, "formatSelection('bold')") + self.assertContains(response, "u:'underline'") + class CopyButtonTests(TestCase): def test_item_has_copy_button_with_original_text(self): diff --git a/locale/de/LC_MESSAGES/django.po b/locale/de/LC_MESSAGES/django.po index 6a490b4..1b6844d 100644 --- a/locale/de/LC_MESSAGES/django.po +++ b/locale/de/LC_MESSAGES/django.po @@ -644,6 +644,46 @@ msgstr "Team %(team)s erstellt. Nutze #%(slug)s für Team-Einträge." msgid "You are now a member of team %(team)s." msgstr "Du bist jetzt Mitglied im Team %(team)s." +#: core/templates/core/_format_toolbar.html +msgid "Bold" +msgstr "Fett" + +#: core/templates/core/_format_toolbar.html +msgid "Italic" +msgstr "Kursiv" + +#: core/templates/core/_format_toolbar.html +msgid "Underline" +msgstr "Unterstrichen" + +#: core/templates/core/_format_toolbar.html +msgid "Inline code" +msgstr "Inline-Code" + +#: core/templates/core/_format_toolbar.html +msgid "Heading" +msgstr "Überschrift" + +#: core/templates/core/_format_toolbar.html +msgid "Quote" +msgstr "Zitat" + +#: core/templates/core/_format_toolbar.html +msgid "Bullet list" +msgstr "Aufzählung" + +#: core/templates/core/_format_toolbar.html +msgid "Numbered list" +msgstr "Nummerierte Liste" + +#: core/templates/core/_format_toolbar.html +msgid "Select text, then format it" +msgstr "Text markieren und anschließend formatieren" + +#: core/templates/core/base.html core/templates/core/index.html +msgid "Text" +msgstr "Text" + #: core/templates/core/base.html core/templates/core/index.html msgid "Type" msgstr "Typ" diff --git a/locale/en/LC_MESSAGES/django.po b/locale/en/LC_MESSAGES/django.po index ee53317..31cbbfd 100644 --- a/locale/en/LC_MESSAGES/django.po +++ b/locale/en/LC_MESSAGES/django.po @@ -638,6 +638,46 @@ msgstr "" msgid "You are now a member of team %(team)s." msgstr "" +#: core/templates/core/_format_toolbar.html +msgid "Bold" +msgstr "Bold" + +#: core/templates/core/_format_toolbar.html +msgid "Italic" +msgstr "Italic" + +#: core/templates/core/_format_toolbar.html +msgid "Underline" +msgstr "Underline" + +#: core/templates/core/_format_toolbar.html +msgid "Inline code" +msgstr "Inline code" + +#: core/templates/core/_format_toolbar.html +msgid "Heading" +msgstr "Heading" + +#: core/templates/core/_format_toolbar.html +msgid "Quote" +msgstr "Quote" + +#: core/templates/core/_format_toolbar.html +msgid "Bullet list" +msgstr "Bullet list" + +#: core/templates/core/_format_toolbar.html +msgid "Numbered list" +msgstr "Numbered list" + +#: core/templates/core/_format_toolbar.html +msgid "Select text, then format it" +msgstr "Select text, then format it" + +#: core/templates/core/base.html core/templates/core/index.html +msgid "Text" +msgstr "Text" + #: core/templates/core/base.html core/templates/core/index.html msgid "Type" msgstr "Type"