Implement tag auto-completion

This commit is contained in:
Sascha Ißbrücker
2019-12-27 12:32:44 +01:00
parent 9ff8356a4d
commit 70b66122c8
16 changed files with 559 additions and 11 deletions

View File

@@ -0,0 +1,155 @@
<script>
export let id;
export let name;
export let value;
export let tags;
let isFocus = false;
let isOpen = false;
let input = null;
let suggestions = [];
let selectedIndex = 0;
function handleFocus() {
isFocus = true;
}
function handleBlur() {
isFocus = false;
close();
}
function handleInput(e) {
input = e.target;
const word = getCurrentWord(e.target);
if (!word) {
close();
return;
}
open(word);
}
function handleKeyDown(e) {
if (isOpen && (e.keyCode === 13 || e.keyCode === 9)) {
const suggestion = suggestions[selectedIndex];
complete(suggestion);
e.preventDefault();
}
if (e.keyCode === 27) {
close();
e.preventDefault();
}
if (e.keyCode === 38) {
updateSelection(-1);
e.preventDefault();
}
if (e.keyCode === 40) {
updateSelection(1);
e.preventDefault();
}
}
function open(word) {
isOpen = true;
updateSuggestions(word);
selectedIndex = 0;
}
function close() {
isOpen = false;
suggestions = [];
selectedIndex = 0;
}
function complete(suggestion) {
const bounds = getCurrentWordBounds(input);
const value = input.value;
input.value = value.substring(0, bounds.start) + suggestion + value.substring(bounds.end);
close();
}
function getCurrentWordBounds() {
const text = input.value;
const end = input.selectionStart;
let start = end;
let currentChar = text.charAt(start - 1);
while (currentChar && currentChar !== ' ' && start > 0) {
start--;
currentChar = text.charAt(start - 1);
}
return {start, end};
}
function getCurrentWord() {
const bounds = getCurrentWordBounds(input);
return input.value.substring(bounds.start, bounds.end);
}
function updateSuggestions(word) {
suggestions = tags.filter(tag => tag.indexOf(word) === 0);
}
function updateSelection(dir) {
const length = suggestions.length;
let newIndex = selectedIndex + dir;
if (newIndex < 0) newIndex = Math.max(length - 1, 0);
if (newIndex >= length) newIndex = 0;
selectedIndex = newIndex;
}
</script>
<div class="form-autocomplete">
<!-- autocomplete input container -->
<div class="form-autocomplete-input form-input" class:is-focused={isFocus}>
<!-- autocomplete real input box -->
<input id="{id}" name="{name}" value="{value ||''}"
class="form-input" type="text" autocomplete="off"
on:input={handleInput} on:keydown={handleKeyDown}
on:focus={handleFocus} on:blur={handleBlur}>
</div>
<!-- autocomplete suggestion list -->
<ul class="menu" class:open={isOpen && suggestions.length > 0}>
<!-- menu list items -->
{#each suggestions as tag,i}
<li class="menu-item" class:selected={selectedIndex === i}>
<a href="#" on:mousedown|preventDefault={() => complete(tag)}>
<div class="tile tile-centered">
<div class="tile-content">
{tag}
</div>
</div>
</a>
</li>
{/each}
</ul>
</div>
<style>
.menu {
display: none;
max-height: 200px;
overflow: auto;
}
.menu.open {
display: block;
}
/* TODO: Should be read from theme */
.menu-item.selected > a {
background: #f1f1fc;
color: #5755d9;
}
</style>

View File

@@ -0,0 +1,6 @@
import TagAutoComplete from './TagAutocomplete.svelte'
export default {
TagAutoComplete
}

View File

@@ -78,6 +78,10 @@ def query_tags(user: User, query_string: str):
return query_set.distinct()
def get_user_tags(user: User):
return Tag.objects.filter(owner=user).all()
def _parse_query_string(query_string):
# Sanitize query params
if not query_string:

View File

@@ -10,6 +10,7 @@ $alternative-color-dark: darken($alternative-color, 5%);
// Import Spectre CSS lib
@import "../../node_modules/spectre.css/src/spectre";
@import "../../node_modules/spectre.css/src/autocomplete";
// Import Spectre icons
@import "../../node_modules/spectre.css/src/icons/icons-core";
@import "../../node_modules/spectre.css/src/icons/icons-navigation";

View File

@@ -8,7 +8,7 @@
<h2>Edit bookmark</h2>
</div>
<form action="{% url 'bookmarks:edit' bookmark_id %}" method="post" class="col-6 col-md-12" novalidate>
{% bookmark_form form %}
{% bookmark_form form all_tags %}
</form>
</section>
</div>

View File

@@ -1,4 +1,5 @@
{% load widget_tweaks %}
{% load static %}
<div class="bookmarks-form">
{% csrf_token %}
@@ -13,7 +14,7 @@
{% endif %}
</div>
<div class="form-group">
<label for="{{ form.title.id_for_label }}" class="form-label">Tags</label>
<label for="{{ form.tag_string.id_for_label }}" class="form-label">Tags</label>
{{ form.tag_string|add_class:"form-input" }}
<div class="form-input-hint">
Enter any number of tags separated by space and <strong>without</strong> the hash (#). If a tag does not
@@ -54,6 +55,26 @@
<a href="{% url 'bookmarks:index' %}" class="btn">Nevermind</a>
</div>
{# Replace tag input with auto-complete component #}
<script src="{% static "bundle.js" %}"></script>
<script type="application/javascript">
const wrapper = document.createElement('div');
const tagInput = document.getElementById('{{ form.tag_string.id_for_label }}');
const allTagsString = '{{ all_tags }}';
const allTags = allTagsString.split(' ');
new linkding.TagAutoComplete({
target: wrapper,
props: {
id: '{{ form.tag_string.id_for_label }}',
name: '{{ form.tag_string.name }}',
value: tagInput.value,
tags: allTags
}
});
tagInput.parentElement.replaceChild(wrapper, tagInput);
</script>
<script type="application/javascript">
/**
* Pre-fill title and description placeholders with metadata from website as soon as URL changes

View File

@@ -8,7 +8,7 @@
<h2>New bookmark</h2>
</div>
<form action="{% url 'bookmarks:new' %}" method="post" class="col-6 col-md-12" novalidate>
{% bookmark_form form auto_close %}
{% bookmark_form form all_tags auto_close %}
</form>
</section>
</div>

View File

@@ -3,16 +3,21 @@ from typing import List
from django import template
from django.core.paginator import Page
from bookmarks.models import BookmarkForm, Tag
from bookmarks.models import BookmarkForm, Tag, build_tag_string
register = template.Library()
@register.inclusion_tag('bookmarks/form.html', name='bookmark_form')
def bookmark_form(form: BookmarkForm, auto_close: bool = False):
def bookmark_form(form: BookmarkForm, all_tags: List[Tag], auto_close: bool = False):
all_tag_names = [tag.name for tag in all_tags]
all_tags_string = build_tag_string(all_tag_names, ' ')
return {
'form': form,
'auto_close': auto_close
'auto_close': auto_close,
'all_tags': all_tags_string
}

View File

@@ -7,6 +7,7 @@ from django.urls import reverse
from bookmarks import queries
from bookmarks.models import Bookmark, BookmarkForm, build_tag_string
from bookmarks.services.bookmarks import create_bookmark, update_bookmark
from bookmarks.queries import get_user_tags
_default_page_size = 30
@@ -56,7 +57,10 @@ def new(request):
if initial_auto_close:
form.initial['auto_close'] = 'true'
return render(request, 'bookmarks/new.html', {'form': form, 'auto_close': initial_auto_close})
all_tags = get_user_tags(request.user)
context = {'form': form, 'auto_close': initial_auto_close, 'all_tags': all_tags}
return render(request, 'bookmarks/new.html', context)
@login_required
@@ -71,7 +75,11 @@ def edit(request, bookmark_id: int):
form = BookmarkForm(instance=bookmark)
form.initial['tag_string'] = build_tag_string(bookmark.tag_names, ' ')
return render(request, 'bookmarks/edit.html', {'form': form, 'bookmark_id': bookmark_id})
all_tags = get_user_tags(request.user)
context = {'form': form, 'bookmark_id': bookmark_id, 'all_tags': all_tags}
return render(request, 'bookmarks/edit.html', context)
@login_required