mirror of
https://github.com/sissbruecker/linkding.git
synced 2025-08-13 13:39:27 +02:00
Implement tag auto-completion
This commit is contained in:
155
bookmarks/components/TagAutocomplete.svelte
Normal file
155
bookmarks/components/TagAutocomplete.svelte
Normal 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>
|
6
bookmarks/components/index.js
Normal file
6
bookmarks/components/index.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import TagAutoComplete from './TagAutocomplete.svelte'
|
||||
|
||||
export default {
|
||||
TagAutoComplete
|
||||
}
|
||||
|
@@ -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:
|
||||
|
@@ -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";
|
||||
|
@@ -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>
|
||||
|
@@ -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
|
||||
|
@@ -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>
|
||||
|
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
@@ -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
|
||||
|
Reference in New Issue
Block a user