Add rich text formatting toolbar
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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' %}"></></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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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+)\]\]')
|
||||
|
||||
@@ -104,6 +104,19 @@ class MarkdownHeadingTests(TestCase):
|
||||
self.assertIn('# comment', rendered)
|
||||
self.assertNotIn('\\# comment', rendered)
|
||||
|
||||
def test_underline_is_allowed(self):
|
||||
self.assertIn('<u>underlined</u>', str(markdown('<u>underlined</u>')))
|
||||
|
||||
|
||||
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):
|
||||
|
||||
Reference in New Issue
Block a user