Add search autocomplete (#53)

* Implement search autocomplete for recent searches

* Implement search autocomplete for bookmarks

* Fix URL encoding of query param

* Add tag suggestions to search autocomplete

Co-authored-by: Sascha Ißbrücker <sissbruecker@lyska.io>
This commit is contained in:
Sascha Ißbrücker 2020-12-31 09:47:51 +01:00 committed by GitHub
parent 816a887d99
commit c13b27e170
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 436 additions and 28 deletions

2
API.md
View File

@ -45,6 +45,8 @@ Example response:
"url": "https://example.com", "url": "https://example.com",
"title": "Example title", "title": "Example title",
"description": "Example description", "description": "Example description",
"website_title": "Website title",
"website_description": "Website description",
"tag_names": [ "tag_names": [
"tag1", "tag1",
"tag2" "tag2"

View File

@ -12,8 +12,23 @@ class TagListField(serializers.ListField):
class BookmarkSerializer(serializers.ModelSerializer): class BookmarkSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Bookmark model = Bookmark
fields = ['id', 'url', 'title', 'description', 'tag_names', 'date_added', 'date_modified'] fields = [
read_only_fields = ['date_added', 'date_modified'] 'id',
'url',
'title',
'description',
'website_title',
'website_description',
'tag_names',
'date_added',
'date_modified'
]
read_only_fields = [
'website_title',
'website_description',
'date_added',
'date_modified'
]
# Override readonly tag_names property to allow passing a list of tag names to create/update # Override readonly tag_names property to allow passing a list of tag names to create/update
tag_names = TagListField() tag_names = TagListField()

View File

@ -0,0 +1,274 @@
<script>
import {SearchHistory} from "./SearchHistory";
import {clampText, debounce, getCurrentWord, getCurrentWordBounds} from "./util";
const searchHistory = new SearchHistory()
export let name;
export let placeholder;
export let value;
export let tags;
export let apiClient;
let isFocus = false;
let isOpen = false;
let suggestions = []
let selectedIndex = undefined;
let input = null;
// Track current search query after loading the page
searchHistory.pushCurrent()
updateSuggestions()
function handleFocus() {
isFocus = true;
}
function handleBlur() {
isFocus = false;
close();
}
function handleInput(e) {
value = e.target.value
debouncedLoadSuggestions()
}
function handleKeyDown(e) {
// Enter
if (isOpen && selectedIndex !== undefined && (e.keyCode === 13 || e.keyCode === 9)) {
const suggestion = suggestions.total[selectedIndex];
if (suggestion) completeSuggestion(suggestion);
e.preventDefault();
}
// Escape
if (e.keyCode === 27) {
close();
e.preventDefault();
}
// Up arrow
if (e.keyCode === 38) {
updateSelection(-1);
e.preventDefault();
}
// Down arrow
if (e.keyCode === 40) {
if (!isOpen) {
loadSuggestions()
} else {
updateSelection(1);
}
e.preventDefault();
}
}
function open() {
isOpen = true;
}
function close() {
isOpen = false;
updateSuggestions()
selectedIndex = undefined
}
function hasSuggestions() {
return suggestions.total.length > 0
}
async function loadSuggestions() {
let suggestionIndex = 0
function nextIndex() {
return suggestionIndex++
}
// Tag suggestions
let tagSuggestions = []
const currentWord = getCurrentWord(input)
if (currentWord && currentWord.length > 1 && currentWord[0] === '#') {
const searchTag = currentWord.substring(1, currentWord.length)
tagSuggestions = (tags || []).filter(tagName => tagName.toLowerCase().indexOf(searchTag.toLowerCase()) === 0)
.slice(0, 5)
.map(tagName => ({
type: 'tag',
index: nextIndex(),
label: `#${tagName}`,
tagName: tagName
}))
}
// Recent search suggestions
const search = searchHistory.getRecentSearches(value, 5).map(value => ({
type: 'search',
index: nextIndex(),
label: value,
value
}))
// Bookmark suggestions
let bookmarks = []
if (value && value.length >= 3) {
const fetchedBookmarks = await apiClient.getBookmarks(value, {limit: 5, offset: 0})
bookmarks = fetchedBookmarks.map(bookmark => {
const fullLabel = bookmark.title || bookmark.website_title || bookmark.url
const label = clampText(fullLabel, 60)
return {
type: 'bookmark',
index: nextIndex(),
label,
bookmark
}
})
}
updateSuggestions(search, bookmarks, tagSuggestions)
if (hasSuggestions()) {
open()
} else {
close()
}
}
const debouncedLoadSuggestions = debounce(loadSuggestions)
function updateSuggestions(search, bookmarks, tagSuggestions) {
search = search || []
bookmarks = bookmarks || []
tagSuggestions = tagSuggestions || []
suggestions = {
search,
bookmarks,
tags: tagSuggestions,
total: [
...tagSuggestions,
...search,
...bookmarks,
]
}
}
function completeSuggestion(suggestion) {
if (suggestion.type === 'search') {
value = suggestion.value
close()
}
if (suggestion.type === 'bookmark') {
window.open(suggestion.bookmark.url, '_blank')
close()
}
if (suggestion.type === 'tag') {
const bounds = getCurrentWordBounds(input);
const inputValue = input.value;
input.value = inputValue.substring(0, bounds.start) + `#${suggestion.tagName} ` + inputValue.substring(bounds.end);
close()
}
}
function updateSelection(dir) {
const length = suggestions.total.length;
if (length === 0) return
if (selectedIndex === undefined) {
selectedIndex = dir > 0 ? 0 : Math.max(length - 1, 0)
return
}
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">
<div class="form-autocomplete-input" class:is-focused={isFocus}>
<input type="search" name="{name}" placeholder="{placeholder}" autocomplete="off" value="{value}"
bind:this={input}
on:input={handleInput} on:keydown={handleKeyDown} on:focus={handleFocus} on:blur={handleBlur}>
</div>
<ul class="menu" class:open={isOpen}>
{#if suggestions.tags.length > 0}
<li class="menu-item group-item">Tags</li>
{/if}
{#each suggestions.tags as suggestion}
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
<div class="tile tile-centered">
<div class="tile-content">
{suggestion.label}
</div>
</div>
</a>
</li>
{/each}
{#if suggestions.search.length > 0}
<li class="menu-item group-item">Recent Searches</li>
{/if}
{#each suggestions.search as suggestion}
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
<div class="tile tile-centered">
<div class="tile-content">
{suggestion.label}
</div>
</div>
</a>
</li>
{/each}
{#if suggestions.bookmarks.length > 0}
<li class="menu-item group-item">Bookmarks</li>
{/if}
{#each suggestions.bookmarks as suggestion}
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
<div class="tile tile-centered">
<div class="tile-content">
{suggestion.label}
</div>
</div>
</a>
</li>
{/each}
</ul>
</div>
<style>
.menu {
display: none;
max-height: 400px;
overflow: auto;
}
.menu.open {
display: block;
}
.form-autocomplete-input {
padding: 0;
}
/* TODO: Should be read from theme */
.menu-item.selected > a {
background: #f1f1fc;
color: #5755d9;
}
.group-item, .group-item:hover {
color: #999999;
text-transform: uppercase;
background: none;
font-size: 0.6rem;
font-weight: bold;
}
</style>

View File

@ -0,0 +1,48 @@
const SEARCH_HISTORY_KEY = 'searchHistory'
const MAX_ENTRIES = 30
export class SearchHistory {
getHistory() {
const historyJson = localStorage.getItem(SEARCH_HISTORY_KEY)
return historyJson ? JSON.parse(historyJson) : {
recent: []
}
}
pushCurrent() {
// Skip if browser is not compatible
if (!window.URLSearchParams) return
const urlParams = new URLSearchParams(window.location.search);
const searchParam = urlParams.get('q');
if (!searchParam) return
this.push(searchParam)
}
push(search) {
const history = this.getHistory()
history.recent.unshift(search)
// Remove duplicates and clamp to max entries
history.recent = history.recent.reduce((acc, cur) => {
if (acc.length >= MAX_ENTRIES) return acc
if (acc.indexOf(cur) >= 0) return acc
acc.push(cur)
return acc
}, [])
const newHistoryJson = JSON.stringify(history)
localStorage.setItem(SEARCH_HISTORY_KEY, newHistoryJson)
}
getRecentSearches(query, max) {
const history = this.getHistory()
return history.recent
.filter(search => !query || search.toLowerCase().indexOf(query.toLowerCase()) >= 0)
.slice(0, max)
}
}

View File

@ -1,4 +1,6 @@
<script> <script>
import {getCurrentWord, getCurrentWordBounds} from "./util";
export let id; export let id;
export let name; export let name;
export let value; export let value;
@ -23,7 +25,7 @@
function handleInput(e) { function handleInput(e) {
input = e.target; input = e.target;
const word = getCurrentWord(); const word = getCurrentWord(input);
suggestions = word ? tags.filter(tag => tag.indexOf(word) === 0) : []; suggestions = word ? tags.filter(tag => tag.indexOf(word) === 0) : [];
@ -73,27 +75,6 @@
close(); 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 updateSelection(dir) { function updateSelection(dir) {
const length = suggestions.length; const length = suggestions.length;

View File

@ -0,0 +1,14 @@
export class ApiClient {
constructor(baseUrl) {
this.baseUrl = baseUrl
}
getBookmarks(query, options = {limit: 100, offset: 0}) {
const encodedQuery = encodeURIComponent(query)
const url = `${this.baseUrl}bookmarks?q=${encodedQuery}&limit=${options.limit}&offset=${options.offset}`
return fetch(url)
.then(response => response.json())
.then(data => data.results)
}
}

View File

@ -1,6 +1,10 @@
import TagAutoComplete from './TagAutocomplete.svelte' import TagAutoComplete from './TagAutocomplete.svelte'
import SearchAutoComplete from './SearchAutoComplete.svelte'
import {ApiClient} from './api'
export default { export default {
TagAutoComplete ApiClient,
TagAutoComplete,
SearchAutoComplete
} }

View File

@ -0,0 +1,37 @@
export function debounce(callback, delay = 250) {
let timeoutId
return (...args) => {
clearTimeout(timeoutId)
timeoutId = setTimeout(() => {
timeoutId = null
callback(...args)
}, delay)
}
}
export function clampText(text, maxChars = 30) {
if(!text || text.length <= 30) return text
return text.substr(0, maxChars) + '...'
}
export function getCurrentWordBounds(input) {
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};
}
export function getCurrentWord(input) {
const bounds = getCurrentWordBounds(input);
return input.value.substring(bounds.start, bounds.end);
}

View File

@ -1,7 +1,12 @@
.bookmarks-page { .bookmarks-page {
.search input[type=search] { .search input[type=search] {
width: 200px; width: 180px;
height: 1.8rem;
@media (min-width: $control-width-md) {
width: 300px;
}
} }
} }

View File

@ -1,4 +1,5 @@
{% extends "bookmarks/layout.html" %} {% extends "bookmarks/layout.html" %}
{% load static %}
{% load shared %} {% load shared %}
{% load bookmarks %} {% load bookmarks %}
@ -13,7 +14,10 @@
<div class="search"> <div class="search">
<form action="{% url 'bookmarks:index' %}" method="get"> <form action="{% url 'bookmarks:index' %}" method="get">
<div class="input-group"> <div class="input-group">
<input type="search" name="q" placeholder="Search for words or #tags" value="{{ query }}"> <span id="search-input-wrap">
<input type="search" name="q" placeholder="Search for words or #tags"
value="{{ query }}">
</span>
<input type="submit" value="Search" class="btn input-group-btn"> <input type="submit" value="Search" class="btn input-group-btn">
</div> </div>
</form> </form>
@ -36,4 +40,24 @@
</section> </section>
</div> </div>
{# Replace search input with auto-complete component #}
<script src="{% static "bundle.js" %}"></script>
<script type="application/javascript">
const currentTagsString = '{{ tags_string }}';
const currentTags = currentTagsString.split(' ');
const apiClient = new linkding.ApiClient('{% url 'bookmarks:api-root' %}')
const wrapper = document.getElementById('search-input-wrap')
const newWrapper = document.createElement('div')
new linkding.SearchAutoComplete({
target: newWrapper,
props: {
name: 'q',
placeholder: 'Search for words or #tags',
value: '{{ query }}',
tags: currentTags,
apiClient
}
})
wrapper.parentElement.replaceChild(newWrapper, wrapper)
</script>
{% endblock %} {% endblock %}

View File

@ -22,5 +22,5 @@ urlpatterns = [
path('settings/export', views.settings.bookmark_export, name='settings.export'), path('settings/export', views.settings.bookmark_export, name='settings.export'),
# API # API
path('api/check_url', views.api.check_url, name='api.check_url'), path('api/check_url', views.api.check_url, name='api.check_url'),
url(r'^api/', include(router.urls)) path('api/', include(router.urls), name='api')
] ]

View File

@ -22,6 +22,8 @@ def index(request):
paginator = Paginator(query_set, _default_page_size) paginator = Paginator(query_set, _default_page_size)
bookmarks = paginator.get_page(page) bookmarks = paginator.get_page(page)
tags = queries.query_tags(request.user, query_string) tags = queries.query_tags(request.user, query_string)
tag_names = [tag.name for tag in tags]
tags_string = build_tag_string(tag_names, ' ')
return_url = generate_index_return_url(page, query_string) return_url = generate_index_return_url(page, query_string)
if request.GET.get('tag'): if request.GET.get('tag'):
@ -32,6 +34,7 @@ def index(request):
context = { context = {
'bookmarks': bookmarks, 'bookmarks': bookmarks,
'tags': tags, 'tags': tags,
'tags_string': tags_string,
'query': query_string if query_string else '', 'query': query_string if query_string else '',
'empty': paginator.count == 0, 'empty': paginator.count == 0,
'return_url': return_url 'return_url': return_url

View File

@ -19,6 +19,7 @@
<sourceFolder url="file://$MODULE_DIR$/polls" isTestSource="false" /> <sourceFolder url="file://$MODULE_DIR$/polls" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/siteroot" isTestSource="false" /> <sourceFolder url="file://$MODULE_DIR$/siteroot" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/bookmarks" isTestSource="false" /> <sourceFolder url="file://$MODULE_DIR$/bookmarks" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/build" />
</content> </content>
<orderEntry type="inheritedJdk" /> <orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />