diff --git a/assets/js/autocomplete.js b/assets/js/autocomplete.js index be3d55d30..9204ee5d5 100644 --- a/assets/js/autocomplete.js +++ b/assets/js/autocomplete.js @@ -1,9 +1,11 @@ /** * Autocomplete. */ +import { throttle } from './utils/events.js'; const cache = {}; -let inputField, originalTerm; +const termsAllowingUnderscores = /^artist:/; +let inputField, originalValue; function removeParent() { const parent = document.querySelector('.autocomplete'); @@ -20,8 +22,8 @@ function changeSelected(firstOrLast, current, sibling) { current.classList.remove('autocomplete__item--selected'); sibling.classList.add('autocomplete__item--selected'); } - else if (current) { // if the next keypress will take the user outside the list, restore the unautocompleted term - inputField.value = originalTerm; + else if (current) { // if the next keypress will take the user outside the list, restore the original term + inputField.value = originalValue; removeSelected(); } else if (firstOrLast) { // if no item in the list is selected, select the first or last @@ -39,11 +41,21 @@ function keydownHandler(event) { if (event.keyCode === 13 || event.keyCode === 27 || event.keyCode === 188) removeParent(); // Enter || Esc || Comma if (event.keyCode === 38 || event.keyCode === 40) { // ArrowUp || ArrowDown const newSelected = document.querySelector('.autocomplete__item--selected'); - if (newSelected) inputField.value = newSelected.dataset.value; + if (newSelected) previewSelected(newSelected.dataset.value); event.preventDefault(); } } +function previewSelected(value) { + const { start, end } = getTermPosition(inputField); + const prefix = start === 0 ? '' : originalValue.slice(0, start); + const suffix = end === originalValue.length ? '' : originalValue.slice(end); + inputField.value = prefix + value + suffix; + const valueEnd = start + value.length; + inputField.selectionStart = valueEnd; + inputField.selectionEnd = valueEnd; +} + function createItem(list, suggestion) { const item = document.createElement('li'); item.className = 'autocomplete__item'; @@ -61,7 +73,7 @@ function createItem(list, suggestion) { }); item.addEventListener('click', () => { - inputField.value = item.dataset.value; + previewSelected(item.dataset.value); inputField.dispatchEvent( new CustomEvent('autocomplete', { detail: { @@ -90,10 +102,10 @@ function createParent() { const parent = document.createElement('div'); parent.className = 'autocomplete'; - // Position the parent below the inputfield + // Position the parent below the inputField parent.style.position = 'absolute'; parent.style.left = `${inputField.offsetLeft}px`; - // Take the inputfield offset, add its height and subtract the amount by which the parent element has scrolled + // Take the inputField offset, add its height and subtract the amount by which the parent element has scrolled parent.style.top = `${inputField.offsetTop + inputField.offsetHeight - inputField.parentNode.scrollTop}px`; // We append the parent at the end of body @@ -104,9 +116,6 @@ function showAutocomplete(suggestions, targetInput) { // Remove old autocomplete suggestions removeParent(); - // Save suggestions in cache - cache[targetInput.value] = suggestions; - // If the input target is not empty, still visible, and suggestions were found if (targetInput.value && targetInput.style.display !== 'none' && suggestions.length) { createParent(); @@ -115,36 +124,102 @@ function showAutocomplete(suggestions, targetInput) { } } -function getSuggestions() { - return fetch(inputField.dataset.acSource + inputField.value).then(response => response.json()); +function getSuggestions(searchTerm) { + return fetch(inputField.dataset.acSource + encodeURIComponent(searchTerm)) + .then(response => response.json()) + .then(suggestions => { + // Save suggestions in cache + cache[searchTerm] = suggestions; + return suggestions; + }); } -function listenAutocomplete() { - let timeout; +/** + * @param {HTMLInputElement} input + * @param {string|number} [start] term start position to force, pass undefined or omit to clear + * @param {string|number} [end] term end position to force, pass undefined or omit to clear + */ +function setTermPosition(input, start, end) { + input.dataset.acTermStart = start; + input.dataset.acTermEnd = end; +} - document.addEventListener('input', event => { - removeParent(); +/** + * @param {HTMLInputElement} input + * @returns {TokenPosition} + */ +function getTermPosition(input) { + const { acTermStart = 0, acTermEnd = input.value.length } = input.dataset; + return { start: Number(acTermStart), end: Number(acTermEnd) }; +} - window.clearTimeout(timeout); - // Use a timeout to delay requests until the user has stopped typing - timeout = window.setTimeout(() => { - inputField = event.target; - originalTerm = inputField.value; - const {ac, acMinLength} = inputField.dataset; +/** + * @param {HTMLInputElement} input + * @param {string} [term] term to force, pass undefined or omit to clear + */ +function setAutocompleteTerm(input, term = '') { + input.dataset.acTerm = term; +} - if (ac && (inputField.value.length >= acMinLength)) { - if (cache[inputField.value]) { - showAutocomplete(cache[inputField.value], event.target); - } - else { - // inputField could get overwritten while the suggestions are being fetched - use event.target - getSuggestions().then(suggestions => showAutocomplete(suggestions, event.target)); - } +/** + * Apply additional transformations to the term before passing to the endpoint + * + * @param {string} term + * @returns {string} + */ +function postprocessTerm(term) { + let processedTerm = term; + // Replaces underscores with spaces where applicable + if (!termsAllowingUnderscores.test(term)) { + processedTerm = processedTerm.replace(/_/g, ' '); + } + // Remove spaces before/after namespace + processedTerm = processedTerm.replace(/^([^:\s]+)\s*:\s*(.*)$/g, '$1:$2'); + return processedTerm; +} - } - }, 300); - }); +function getSearchTerm(target) { + const term = typeof target.dataset.acTerm !== 'undefined' ? target.dataset.acTerm : originalValue; + return postprocessTerm(term); +} + +const handleAutocompleteInner = throttle(300, target => { + inputField = target; + originalValue = target.value; + const searchTerm = getSearchTerm(target); + const { ac, acMinLength } = target.dataset; + + if (!ac) return; + if (isNaN(acMinLength)) throw new Error(`Autocomplete minimum length "${acMinLength}" is invalid`); + + if (searchTerm.length >= acMinLength) { + if (cache[searchTerm]) { + showAutocomplete(cache[searchTerm], target); + } + else { + getSuggestions(searchTerm).then(suggestions => showAutocomplete(suggestions, target)); + } + } +}); + +/** + * @typedef ObjectWithTarget + * @property {EventTarget} target + */ + +/** + * @param {ObjectWithTarget} event + */ +function handleAutocomplete(event) { + removeParent(); + + handleAutocompleteInner(event.target); +} + +function listenAutocomplete() { + // Use a timeout to delay requests until the user has stopped typing + document.addEventListener('input', handleAutocomplete); // If there's a click outside the inputField, remove autocomplete document.addEventListener('click', event => { @@ -152,4 +227,4 @@ function listenAutocomplete() { }); } -export { listenAutocomplete }; +export { listenAutocomplete, handleAutocomplete, setTermPosition, getTermPosition, setAutocompleteTerm }; diff --git a/assets/js/search.js b/assets/js/search.js index 864ea2316..4d7deb199 100644 --- a/assets/js/search.js +++ b/assets/js/search.js @@ -1,6 +1,8 @@ import { $, $$ } from './utils/dom'; import { addTag } from './tagsinput'; +let form; + function showHelp(subject, type) { $$('[data-search-help]').forEach(helpBox => { if (helpBox.getAttribute('data-search-help') === type) { @@ -27,7 +29,7 @@ function selectLast(field, characterCount) { } function executeFormHelper(e) { - const searchField = $('.js-search-field'); + const searchField = $('.js-search-field', form); const attr = name => e.target.getAttribute(name); attr('data-search-add') && addTag(searchField, attr('data-search-add')); @@ -37,7 +39,7 @@ function executeFormHelper(e) { } function setupSearch() { - const form = $('.js-search-form'); + form = $('.js-search-form'); form && form.addEventListener('click', executeFormHelper); } diff --git a/assets/js/search_autocomplete.js b/assets/js/search_autocomplete.js new file mode 100644 index 000000000..650e915c8 --- /dev/null +++ b/assets/js/search_autocomplete.js @@ -0,0 +1,270 @@ +import { $$ } from './utils/dom.js'; +import { handleAutocomplete, setAutocompleteTerm, setTermPosition } from './autocomplete.js'; +import store from './utils/store.js'; + +const LITERAL_FIELDS = [ + 'id', + 'width', + 'height', + 'comment_count', + 'score', + 'upvotes', + 'downvotes', 'faves', + 'uploader_id', + 'faved_by_id', + 'tag_count', + 'pixels', + 'size', + 'aspect_ratio', + 'wilson_score', + 'duration', + 'created_at', + 'updated_at', + 'first_seen_at', + 'faved_by', + 'orig_sha512_hash', + 'sha512_hash', + 'uploader', + 'source_url', + 'original_format', + 'mime_type', + 'description', + 'gallery_id', + 'favourited_by_users', + 'favourited_by_user_ids', +]; + +/** + * A regular expression that matches any terms that should not be autocompleted + */ +const ignoredTermRegex = new RegExp(`^(?:${LITERAL_FIELDS.join('|')})(?:\\.[gl]te|:)`); + +/** + * Checks if autocompletion should ignore the specified term + * @param {string} term + * @returns {boolean} + */ +function isIgnoredTerm(term) { + return ignoredTermRegex.test(term); +} + +/** + * @typedef {TokenPosition & TokenValue} ParserToken + */ + +/** + * @typedef TokenValue + * @property {string} value + */ + +/** + * @typedef TokenPosition + * @property {number} start + * @property {number} end + */ + +/** + * Extract the current search term form an input string based on the cursor position + * @param {string} input + * @param {number} cursorPos + * @return {ParserToken} + */ +function extractTerm(input, cursorPos) { + const inputLength = input.length; + if (cursorPos > inputLength) { + throw new Error('Cursor position is outside input value'); + } + + // First we need to find the group closest to the cursor + let depth = 0; + /** + * @type {Object.} + */ + const groups = { + 0: { + start: 0, + end: 0, + }, + }; + const parenBalance = { + 0: 0, + }; + let pos = 0; + while (pos < inputLength) { + if (input[pos] === '(') { + if (pos >= cursorPos) { + // New group is starting past the cursor position, we found what we came for + break; + } + + if (/(?:^\s*|(?:,|&&|\|\|)\s*|\s+AND\s+|\s+OR\s+)$/.test(input.substring(0, pos))) { + depth++; + const start = pos + 1; + groups[depth] = { + start, + end: start, + }; + parenBalance[depth] = 0; + pos++; + continue; + } + else { + parenBalance[depth]++; + } + } + else if (input[pos] === ')') { + if (pos >= cursorPos) { + // Target acquired + break; + } + + parenBalance[depth] = Math.max(0, parenBalance[depth] - 1); + + if (parenBalance[depth] <= 0) { + if (parenBalance[depth] === 0) { + depth = Math.max(0, depth - 1); + } + pos++; + continue; + } + } + + groups[depth].end = pos + 1; + pos++; + } + + // Now we refine the position within the group + const { start: groupStart, end: groupEnd } = groups[depth]; + /** + * @type {Object.} + */ + const terms = { + 0: { + value: '', + start: groupStart, + end: groupStart, + }, + }; + let currentTermIndex = 0; + pos = groupStart; + while (pos < groupEnd) { + const remainingString = input.substring(pos, groupEnd); + const specialTokenStart = remainingString.match(/^(?:(?:,|&&|\|\|)\s*|\s+AND\s+|\s+OR\s+|!\s*|NOT\s+)/); + if (specialTokenStart !== null) { + const specialToken = specialTokenStart[0]; + if (specialToken.trim().length > 0) { + pos += specialToken.length; + if (pos > cursorPos) { + // Bingo + break; + } + + currentTermIndex++; + terms[currentTermIndex] = { + value: '', + start: pos, + end: pos, + }; + continue; + } + } + + terms[currentTermIndex].value += input[pos]; + terms[currentTermIndex].end = pos + 1; + pos++; + } + return terms[currentTermIndex]; +} + +/** + * @param {HTMLInputElement} input + * @returns {ParserToken|null} + */ +function grabToken(input) { + const { selectionStart: start, selectionEnd: end, value } = input; + // If text is selected disable autocompletion + if (end !== start) { + return null; + } + return extractTerm(value, start); +} + +let inputTimeout; +const last = { + value: null, + term: null, + cursorPos: null, +}; + +function handleInput(event) { + window.clearTimeout(inputTimeout); + inputTimeout = window.setTimeout(() => { + const field = event.target; + const cursorPos = field.selectionStart === field.selectionEnd + ? field.selectionStart + : null; + + // eslint-disable-next-line default-case + switch (event.type) { + // The original autocomplete listener waits for this event + case 'input': + // Prevent bubbling, we'll handle it ourselves + event.stopPropagation(); + break; + case 'keydown': + if (event.keyCode === 38 || event.keyCode === 40) { // ArrowUp || ArrowDown + // Ignore these events, should be handled by autocomplete + return; + } + break; + } + + let term; + if (last.value === field.value && cursorPos === last.cursorPos) { + term = last.term; + } + else { + term = grabToken(field); + last.value = field.value; + last.term = term; + } + + if (term && !isIgnoredTerm(term.value)) { + setAutocompleteTerm(field, term.value); + setTermPosition(field, term.start, term.end); + handleAutocomplete(event); + } + else { + setAutocompleteTerm(field); + setTermPosition(field); + } + }, 100); +} + +function getAutocompleteSource() { + return store.get('extended_search_ac') + ? '/search/autocomplete?term=' + : '/tags/autocomplete?term='; +} + +function setupSearchAutocomplete() { + if (store.get('disable_search_ac')) return; + + const fields = $$('.js-search-field'); + + fields.forEach(field => { + field.setAttribute('autocomplete', 'off'); + field.setAttribute('autocapitalize', 'none'); + field.dataset.ac = 'true'; + field.dataset.acSource = getAutocompleteSource(); + field.dataset.acMinLength = '3'; + + field.addEventListener('input', handleInput); + // Handle text cursor movement inside the input + field.addEventListener('keydown', handleInput); + field.addEventListener('click', handleInput); + field.addEventListener('focus', handleInput); + }); +} + +export { setupSearchAutocomplete }; diff --git a/assets/js/tagsinput.js b/assets/js/tagsinput.js index 1df125a73..e7c3499f4 100644 --- a/assets/js/tagsinput.js +++ b/assets/js/tagsinput.js @@ -144,7 +144,7 @@ function fancyEditorRequested(tagBlock) { function setupTagListener() { document.addEventListener('addtag', event => { - if (event.target.value) event.target.value += ', '; + if (event.target.value) event.target.value = event.target.value.replace(/(,\s*)?$/, ', '); event.target.value += event.detail.name; }); } diff --git a/assets/js/utils/events.js b/assets/js/utils/events.js index 51c46a135..eb1e8bfbb 100644 --- a/assets/js/utils/events.js +++ b/assets/js/utils/events.js @@ -22,3 +22,35 @@ export function delegate(node, event, selectors) { } }); } + +/** + * @param fn + * @param delay + * @return {function} + * @returns A function that will not execute `fn` until it hasn't been called for `delay` ms + */ +export function throttle(delay, fn) { + let timeout; + return (...args) => { + window.clearTimeout(timeout); + timeout = setTimeout(() => fn(...args), delay); + }; +} + +/** + * @param fn + * @param delay + * @return {function} + * @returns A function that will only execute `fn` at most once every `delay` ms + */ +export function debounce(delay, fn) { + let timeout = null; + return (...args) => { + if (timeout !== null) return; + timeout = setTimeout(() => { + fn(...args); + window.clearTimeout(timeout); + timeout = null; + }, delay); + }; +} diff --git a/assets/js/when-ready.js b/assets/js/when-ready.js index 4f52a875e..2414a595f 100644 --- a/assets/js/when-ready.js +++ b/assets/js/when-ready.js @@ -2,38 +2,39 @@ * Functions to execute when the DOM is ready */ -import { whenReady } from './utils/dom'; +import { whenReady } from './utils/dom'; -import { showOwnedComments } from './communications/comment'; -import { showOwnedPosts } from './communications/post'; +import { showOwnedComments } from './communications/comment'; +import { showOwnedPosts } from './communications/post'; -import { listenAutocomplete } from './autocomplete'; -import { loadBooruData } from './booru'; -import { registerEvents } from './boorujs'; -import { setupBurgerMenu } from './burger'; -import { bindCaptchaLinks } from './captcha'; -import { setupComments } from './comment'; -import { setupDupeReports } from './duplicate_reports.js'; -import { setFingerprintCookie } from './fingerprint'; -import { setupGalleryEditing } from './galleries'; -import { initImagesClientside } from './imagesclientside'; -import { bindImageTarget } from './image_expansion'; -import { setupEvents } from './misc'; -import { setupNotifications } from './notifications'; -import { setupPreviews } from './preview'; -import { setupQuickTag } from './quick-tag'; -import { initializeListener } from './resizablemedia'; -import { setupSettings } from './settings'; -import { listenForKeys } from './shortcuts'; -import { initTagDropdown } from './tags'; -import { setupTagListener } from './tagsinput'; -import { setupTagEvents } from './tagsmisc'; -import { setupTimestamps } from './timeago'; -import { setupImageUpload } from './upload'; -import { setupSearch } from './search'; -import { setupToolbar } from './textiletoolbar'; -import { hideStaffTools } from './staffhider'; -import { pollOptionCreator } from './poll'; +import { listenAutocomplete } from './autocomplete'; +import { loadBooruData } from './booru'; +import { registerEvents } from './boorujs'; +import { setupBurgerMenu } from './burger'; +import { bindCaptchaLinks } from './captcha'; +import { setupComments } from './comment'; +import { setupDupeReports } from './duplicate_reports.js'; +import { setFingerprintCookie } from './fingerprint'; +import { setupGalleryEditing } from './galleries'; +import { initImagesClientside } from './imagesclientside'; +import { bindImageTarget } from './image_expansion'; +import { setupEvents } from './misc'; +import { setupNotifications } from './notifications'; +import { setupPreviews } from './preview'; +import { setupQuickTag } from './quick-tag'; +import { initializeListener } from './resizablemedia'; +import { setupSettings } from './settings'; +import { listenForKeys } from './shortcuts'; +import { initTagDropdown } from './tags'; +import { setupTagListener } from './tagsinput'; +import { setupTagEvents } from './tagsmisc'; +import { setupTimestamps } from './timeago'; +import { setupImageUpload } from './upload'; +import { setupSearch } from './search'; +import { setupSearchAutocomplete } from './search_autocomplete.js'; +import { setupToolbar } from './textiletoolbar'; +import { hideStaffTools } from './staffhider'; +import { pollOptionCreator } from './poll'; whenReady(() => { @@ -63,6 +64,7 @@ whenReady(() => { setupTimestamps(); setupImageUpload(); setupSearch(); + setupSearchAutocomplete(); setupToolbar(); hideStaffTools(); pollOptionCreator(); diff --git a/lib/philomena_web/autocomplete.ex b/lib/philomena_web/autocomplete.ex new file mode 100644 index 000000000..498131132 --- /dev/null +++ b/lib/philomena_web/autocomplete.ex @@ -0,0 +1,26 @@ +defmodule PhilomenaWeb.Autocomplete do + alias Philomena.Elasticsearch + alias Philomena.Tags.Tag + import Ecto.Query + + def fetch_tags(query) do + Tag + |> Elasticsearch.search_definition( + query, + %{page_size: 5} + ) + |> Elasticsearch.search_records(preload(Tag, :aliased_tag)) + |> Enum.map(&(&1.aliased_tag || &1)) + |> Enum.uniq_by(& &1.id) + |> Enum.sort_by(&(-&1.images_count)) + |> Enum.map(&%{label: "#{&1.name} (#{&1.images_count})", value: &1.name}) + end + + def normalize_query(%{"term" => term}) when is_binary(term) and byte_size(term) > 2 do + term + |> String.downcase() + |> String.trim() + end + + def normalize_query(_params), do: nil +end diff --git a/lib/philomena_web/controllers/search/autocomplete_controller.ex b/lib/philomena_web/controllers/search/autocomplete_controller.ex new file mode 100644 index 000000000..9bb8075f5 --- /dev/null +++ b/lib/philomena_web/controllers/search/autocomplete_controller.ex @@ -0,0 +1,28 @@ +defmodule PhilomenaWeb.Search.AutocompleteController do + use PhilomenaWeb, :controller + + alias Philomena.Elasticsearch + alias Philomena.{Tags, Tags.Tag} + alias PhilomenaWeb.Autocomplete + + def show(conn, params) do + tags = + case Autocomplete.normalize_query(params) do + nil -> + [] + + term -> + with {:ok, query} <- Tags.Query.compile(term) do + Autocomplete.fetch_tags(%{ + query: query, + sort: [%{images: :desc}, %{name: :asc}] + }) + else + {:error, msg} -> [] + end + end + + conn + |> json(tags) + end +end diff --git a/lib/philomena_web/controllers/setting_controller.ex b/lib/philomena_web/controllers/setting_controller.ex index b23707df9..892d9969e 100644 --- a/lib/philomena_web/controllers/setting_controller.ex +++ b/lib/philomena_web/controllers/setting_controller.ex @@ -42,6 +42,8 @@ defmodule PhilomenaWeb.SettingController do |> set_cookie(user_params, "chan_nsfw", "chan_nsfw") |> set_cookie(user_params, "hide_staff_tools", "hide_staff_tools") |> set_cookie(user_params, "hide_uploader", "hide_uploader") + |> set_cookie(user_params, "extended_search_ac", "extended_search_ac") + |> set_cookie(user_params, "disable_search_ac", "disable_search_ac") end defp set_cookie(conn, params, param_name, cookie_name) do diff --git a/lib/philomena_web/controllers/tag/autocomplete_controller.ex b/lib/philomena_web/controllers/tag/autocomplete_controller.ex index 664338528..0bb512b98 100644 --- a/lib/philomena_web/controllers/tag/autocomplete_controller.ex +++ b/lib/philomena_web/controllers/tag/autocomplete_controller.ex @@ -3,46 +3,29 @@ defmodule PhilomenaWeb.Tag.AutocompleteController do alias Philomena.Elasticsearch alias Philomena.Tags.Tag - import Ecto.Query + alias PhilomenaWeb.Autocomplete def show(conn, params) do tags = - case query(params) do + case Autocomplete.normalize_query(params) do nil -> [] term -> - Tag - |> Elasticsearch.search_definition( - %{ - query: %{ - bool: %{ - should: [ - %{prefix: %{name: term}}, - %{prefix: %{name_in_namespace: term}} - ] - } - }, - sort: %{images: :desc} + Autocomplete.fetch_tags(%{ + query: %{ + bool: %{ + should: [ + %{prefix: %{name: term}}, + %{prefix: %{name_in_namespace: term}} + ] + } }, - %{page_size: 5} - ) - |> Elasticsearch.search_records(preload(Tag, :aliased_tag)) - |> Enum.map(&(&1.aliased_tag || &1)) - |> Enum.uniq_by(& &1.id) - |> Enum.sort_by(&(-&1.images_count)) - |> Enum.map(&%{label: "#{&1.name} (#{&1.images_count})", value: &1.name}) + sort: %{images: :desc} + }) end conn |> json(tags) end - - defp query(%{"term" => term}) when is_binary(term) and byte_size(term) > 2 do - term - |> String.downcase() - |> String.trim() - end - - defp query(_params), do: nil end diff --git a/lib/philomena_web/router.ex b/lib/philomena_web/router.ex index d3dadef5a..25c6cd8c9 100644 --- a/lib/philomena_web/router.ex +++ b/lib/philomena_web/router.ex @@ -455,6 +455,7 @@ defmodule PhilomenaWeb.Router do end scope "/search", Search, as: :search do + resources "/autocomplete", AutocompleteController, only: [:show], singleton: true resources "/reverse", ReverseController, only: [:index, :create] end diff --git a/lib/philomena_web/templates/layout/_header.html.slime b/lib/philomena_web/templates/layout/_header.html.slime index 347f505ad..4caf3d2f4 100644 --- a/lib/philomena_web/templates/layout/_header.html.slime +++ b/lib/philomena_web/templates/layout/_header.html.slime @@ -12,7 +12,7 @@ header.header i.fa.fa-upload = form_for @conn, Routes.search_path(@conn, :index), [method: "get", class: "header__search flex flex--no-wrap flex--centered", enforce_utf8: false], fn f -> - input.input.header__input.header__input--search#q name="q" title="For terms all required, separate with ',' or 'AND'; also supports 'OR' for optional terms and '-' or 'NOT' for negation. Search with a blank query for more options or click the ? for syntax help." value=@conn.params["q"] placeholder="Search (ex: "fox, red eyes, oc")" autocapitalize="none" autocorrect="off" spellcheck="false" + input.input.header__input.header__input--search.js-search-field#q name="q" title="For terms all required, separate with ',' or 'AND'; also supports 'OR' for optional terms and '-' or 'NOT' for negation. Search with a blank query for more options or click the ? for syntax help." value=@conn.params["q"] placeholder="Search (ex: "fox, red eyes, oc")" autocapitalize="none" autocorrect="off" spellcheck="false" = if present?(@conn.params["sf"]) do input type="hidden" name="sf" value=@conn.params["sf"] diff --git a/lib/philomena_web/templates/setting/edit.html.slime b/lib/philomena_web/templates/setting/edit.html.slime index 5529eca24..f23cab1af 100644 --- a/lib/philomena_web/templates/setting/edit.html.slime +++ b/lib/philomena_web/templates/setting/edit.html.slime @@ -118,6 +118,20 @@ h1 Content Settings => label f, :chan_nsfw, "Show NSFW channels" => checkbox f, :chan_nsfw, checked: @conn.cookies["chan_nsfw"] == "true" .fieldlabel: i Show streams marked as NSFW on the channels page. + .field + => label f, :disable_search_ac, "Disable tag autocompletion on search inputs" + => checkbox f, :disable_search_ac, checked: @conn.cookies["disable_search_ac"] == "true" + .fieldlabel: i By default the various image search inputs across the site will give you tag suggestions as you type. You can disable this here to revert to the previous behavior. + .field + => label f, :extended_search_ac, "Autocomplete search fields using extended syntax" + => checkbox f, :extended_search_ac, checked: @conn.cookies["extended_search_ac"] == "true" + .fieldlabel + i + | By default the search fields on the site autocomplete the same syntax as the tag input, but you can chose to enable the + a<> href="/tags" + | Tags + | page syntax instead, letting you use wildcards for example. Does not change the behavior of search itself, only the suggestions. + = if staff?(@conn.assigns.current_user) do .field => label f, :hide_staff_tools