Add rich text formatting toolbar

This commit is contained in:
rucki
2026-08-27 10:57:14 +02:00
parent 18591ce90f
commit ade878a0cd
11 changed files with 153 additions and 4 deletions
+1
View File
@@ -11,6 +11,7 @@
</div>
<textarea class="form-control" name="content" rows="5" x-model="text" @keydown="onKeydown($event)" @input="onInput($event)" @paste="handlePaste($event)">{{ item.content }}</textarea>
<textarea class="form-control" name="comment" rows="3" placeholder="{% trans 'Comment, e.g. same task already captured [[12]]' %}" @keydown="onKeydown($event)" @input="onInput($event)" @paste="handlePaste($event)">{{ item.comment }}</textarea>
{% include 'core/_format_toolbar.html' %}
<div class="slash-menu list-group shadow" x-show="open" @click.outside="open=false" x-cloak>
<template x-for="(cmd, idx) in filtered" :key="cmd.token"><div><div x-show="cmd.group && (idx === 0 || filtered[idx-1].group !== cmd.group)" class="slash-group-header" x-text="cmd.group"></div><button type="button" class="list-group-item list-group-item-action w-100 text-start" :class="{'active': idx === active}" @mouseenter="active=idx" @mousedown.prevent="insert(cmd.token)"><strong x-text="cmd.token"></strong> <span class="text-muted" x-text="cmd.label"></span></button></div></template>
</div>
+16
View File
@@ -0,0 +1,16 @@
{% load i18n %}
<div class="format-toolbar mt-2" @click.outside="showFormatting=false">
<button type="button" class="btn btn-sm btn-outline-secondary format-toggle" :class="{'active':showFormatting}" @mousedown.prevent="currentEl=document.activeElement;showFormatting=!showFormatting" title="{% trans 'Formatting' %}" aria-label="{% trans 'Formatting' %}">Aa</button>
<div class="format-options shadow-sm" x-show="showFormatting" x-cloak>
<button type="button" class="btn btn-sm btn-light fw-bold" @mousedown.prevent="formatSelection('bold')" title="{% trans 'Bold' %}">B</button>
<button type="button" class="btn btn-sm btn-light fst-italic" @mousedown.prevent="formatSelection('italic')" title="{% trans 'Italic' %}">I</button>
<button type="button" class="btn btn-sm btn-light text-decoration-underline" @mousedown.prevent="formatSelection('underline')" title="{% trans 'Underline' %}">U</button>
<span class="format-divider"></span>
<button type="button" class="btn btn-sm btn-light font-monospace" @mousedown.prevent="formatSelection('code')" title="{% trans 'Inline code' %}">&lt;/&gt;</button>
<button type="button" class="btn btn-sm btn-light" @mousedown.prevent="formatSelection('heading')" title="{% trans 'Heading' %}">H</button>
<button type="button" class="btn btn-sm btn-light" @mousedown.prevent="formatSelection('quote')" title="{% trans 'Quote' %}">❯</button>
<button type="button" class="btn btn-sm btn-light" @mousedown.prevent="formatSelection('list')" title="{% trans 'Bullet list' %}">•</button>
<button type="button" class="btn btn-sm btn-light" @mousedown.prevent="formatSelection('numbered')" title="{% trans 'Numbered list' %}">1.</button>
</div>
<span class="small text-muted ms-2 d-none d-md-inline">{% trans 'Select text, then format it' %}</span>
</div>
File diff suppressed because one or more lines are too long
+35
View File
@@ -119,6 +119,7 @@
</div>
<details class="mobile-tag-picker mb-2"><summary class="btn btn-sm btn-outline-secondary">{% trans "Add tags" %}</summary><div class="mobile-tags flex-wrap gap-2 mt-2">{% for tag in tags %}<button type="button" class="btn btn-sm btn-outline-secondary" @click="addToken('#{{ tag.name|escapejs }}')">#{{ tag.name }}</button>{% endfor %}</div></details>
{{ form.content }}
{% include 'core/_format_toolbar.html' %}
<div class="slash-menu list-group shadow" x-show="open" @click.outside="open=false" x-cloak>
<template x-for="(cmd, idx) in filtered" :key="cmd.token"><div><div x-show="cmd.group && (idx === 0 || filtered[idx-1].group !== cmd.group)" class="slash-group-header" x-text="cmd.group"></div><button type="button" class="list-group-item list-group-item-action w-100 text-start" :class="{'active': idx === active}" @mouseenter="active=idx" @mousedown.prevent="insert(cmd.token)"><strong x-text="cmd.token"></strong> <span class="text-muted" x-text="cmd.label"></span></button></div></template>
</div>
@@ -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:['<u>','</u>','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;
+1
View File
@@ -17,6 +17,7 @@
</div>
<textarea class="form-control" name="content" rows="18" x-model="text" @keydown="onKeydown($event)" @input="onInput($event)" @paste="handlePaste($event)">{{ item.content }}</textarea>
<textarea class="form-control" name="comment" rows="5" placeholder="{% trans 'Comment' %}" @keydown="onKeydown($event)" @input="onInput($event)" @paste="handlePaste($event)">{{ item.comment }}</textarea>
{% include 'core/_format_toolbar.html' %}
<div class="slash-menu list-group shadow" x-show="open" @click.outside="open=false" x-cloak>
<template x-for="(cmd, idx) in filtered" :key="cmd.token"><div><div x-show="cmd.group && (idx === 0 || filtered[idx-1].group !== cmd.group)" class="slash-group-header" x-text="cmd.group"></div><button type="button" class="list-group-item list-group-item-action w-100 text-start" :class="{'active': idx === active}" @mouseenter="active=idx" @mousedown.prevent="insert(cmd.token)"><strong x-text="cmd.token"></strong> <span class="text-muted" x-text="cmd.label"></span></button></div></template>
</div>
+1
View File
@@ -28,6 +28,7 @@
</div>
<details class="mobile-tag-picker mb-2"><summary class="btn btn-sm btn-outline-secondary">{% trans "Add tags" %}</summary><div class="mobile-tags flex-wrap gap-2 mt-2">{% for tag in tags %}<button type="button" class="btn btn-sm btn-outline-secondary" @click="addToken('#{{ tag.name|escapejs }}')">#{{ tag.name }}</button>{% endfor %}</div></details>
{{ form.content }}
{% include 'core/_format_toolbar.html' %}
<div class="slash-menu list-group shadow" x-show="open" @click.outside="open=false" x-cloak><template x-for="(cmd, idx) in filtered" :key="cmd.token"><div><div x-show="cmd.group && (idx === 0 || filtered[idx-1].group !== cmd.group)" class="slash-group-header" x-text="cmd.group"></div><button type="button" class="list-group-item list-group-item-action w-100 text-start" :class="{'active': idx === active}" @mouseenter="active=idx" @mousedown.prevent="insert(cmd.token)"><strong x-text="cmd.token"></strong> <span class="text-muted" x-text="cmd.label"></span></button></div></template></div>
<div class="d-flex align-items-center gap-2 mt-2 quick-actions"><input class="form-control desktop-file" type="file" name="files" multiple accept="image/*,.pdf,.txt,.md" x-ref="fileInput" @change="previewFile($event)"><div class="mobile-media gap-2 w-100"><label class="btn btn-outline-secondary flex-fill mb-0">{% trans "Gallery" %}<input class="d-none" type="file" name="files" multiple accept="image/*" @change="previewFile($event)"></label><label class="btn btn-outline-secondary flex-fill mb-0">{% trans "Photo" %}<input class="d-none" type="file" name="files" accept="image/*" capture="environment" @change="previewFile($event)"></label></div><button class="btn btn-dark px-4">{% trans "Save" %}</button></div>
</form>
+1
View File
@@ -16,6 +16,7 @@
</div>
</div>
<textarea class="form-control" name="content" rows="14" placeholder="Text … #tag [[ ![[" x-model="text" @keydown="onKeydown($event)" @input="onInput($event)" @paste="handlePaste($event)"></textarea>
{% include 'core/_format_toolbar.html' %}
<div class="slash-menu list-group shadow" x-show="open" @click.outside="open=false" x-cloak>
<template x-for="(cmd, idx) in filtered" :key="cmd.token"><div><div x-show="cmd.group && (idx === 0 || filtered[idx-1].group !== cmd.group)" class="slash-group-header" x-text="cmd.group"></div><button type="button" class="list-group-item list-group-item-action w-100 text-start" :class="{'active': idx === active}" @mouseenter="active=idx" @mousedown.prevent="insert(cmd.token)"><strong x-text="cmd.token"></strong> <span class="text-muted" x-text="cmd.label"></span></button></div></template>
</div>