feat(filters): move filter query to contenteditable

This commit is contained in:
kolaente 2024-03-05 17:47:06 +01:00
parent 11bc4764de
commit c058835874
Signed by: konrad
GPG Key ID: F40E70337AB24C9B
2 changed files with 263 additions and 55 deletions

View File

@ -1,10 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import {computed, nextTick, ref, watch} from 'vue' import {nextTick, ref, watch} from 'vue'
import {useAutoHeightTextarea} from '@/composables/useAutoHeightTextarea' import {useAutoHeightTextarea} from '@/composables/useAutoHeightTextarea'
import DatepickerWithValues from '@/components/date/datepickerWithValues.vue' import DatepickerWithValues from '@/components/date/datepickerWithValues.vue'
import UserService from "@/services/user"; import UserService from '@/services/user'
import {getAvatarUrl, getDisplayName} from "@/models/user"; import {getAvatarUrl, getDisplayName} from '@/models/user'
import {createRandomID} from "@/helpers/randomId"; import {createRandomID} from '@/helpers/randomId'
const { const {
modelValue, modelValue,
@ -35,6 +35,7 @@ const dateFields = [
'doneAt', 'doneAt',
'reminders', 'reminders',
] ]
const dateFieldsRegex = '(' + dateFields.join('|') + ')'
const assigneeFields = [ const assigneeFields = [
'assignees', 'assignees',
@ -84,45 +85,48 @@ function unEscapeHtml(unsafe: string): string {
.replace(/&lt;/g, '<') .replace(/&lt;/g, '<')
.replace(/&gt;/g, '>') .replace(/&gt;/g, '>')
.replace(/&quot/g, '"') .replace(/&quot/g, '"')
.replace(/&#039;/g, "'") .replace(/&#039;/g, '\'')
} }
const highlightedFilterQuery = computed(() => { const TOKEN_REGEX = '(&lt;|&gt;|&lt;=|&gt;=|=|!=)'
function getHighlightedFilterQuery() {
let highlighted = escapeHtml(filterQuery.value) let highlighted = escapeHtml(filterQuery.value)
dateFields dateFields
.forEach(o => { .forEach(o => {
const pattern = new RegExp(o + '\\s*(&lt;|&gt;|&lt;=|&gt;=|=|!=)\\s*([\'"]?)([^\'"\\s]+\\1?)?', 'ig'); const pattern = new RegExp(o + '\\s*' + TOKEN_REGEX + '\\s*([\'"]?)([^\'"\\s]+\\1?)?', 'ig')
highlighted = highlighted.replaceAll(pattern, (match, token, start, value, position) => { highlighted = highlighted.replaceAll(pattern, (match, token, start, value, position) => {
if (typeof value === 'undefined') { if (typeof value === 'undefined') {
value = '' value = ' '
} }
return `${o} ${token} <button class="button is-primary filter-query__date_value" data-position="${position}">${value}</button><span class="filter-query__date_value_placeholder">${value}</span>`
return `${o} ${token} <button class="button is-primary filter-query__date_value" data-position="${position}">${value}</button>`
}) })
}) })
assigneeFields assigneeFields
.forEach(f => { .forEach(f => {
const pattern = new RegExp(f + '\\s*(&lt;|&gt;|&lt;=|&gt;=|=|!=)\\s*([\'"]?)([^\'"\\s]+\\1?)?', 'ig'); const pattern = new RegExp(f + '\\s*' + TOKEN_REGEX + '\\s*([\'"]?)([^\'"\\s]+\\1?)?', 'ig')
highlighted = highlighted.replaceAll(pattern, (match, token, start, value) => { highlighted = highlighted.replaceAll(pattern, (match, token, start, value) => {
if (typeof value === 'undefined') { if (typeof value === 'undefined') {
value = '' value = ''
} }
const id = createRandomID(32) const id = createRandomID(32)
userService.getAll({}, {s: value}).then(users => { userService.getAll({}, {s: value}).then(users => {
if (users.length > 0) { if (users.length > 0) {
const displayName = getDisplayName(users[0]) const displayName = getDisplayName(users[0])
const nameTag = document.createElement('span') const nameTag = document.createElement('span')
nameTag.innerText = displayName nameTag.innerText = displayName
const avatar = document.createElement('img') const avatar = document.createElement('img')
avatar.src = getAvatarUrl(users[0], 20) avatar.src = getAvatarUrl(users[0], 20)
avatar.height = 20 avatar.height = 20
avatar.width = 20 avatar.width = 20
avatar.alt = displayName avatar.alt = displayName
// TODO: caching // TODO: caching
nextTick(() => { nextTick(() => {
const assigneeValue = document.getElementById(id) const assigneeValue = document.getElementById(id)
assigneeValue.innerText = '' assigneeValue.innerText = ''
@ -131,7 +135,7 @@ const highlightedFilterQuery = computed(() => {
}) })
} }
}) })
return `${f} ${token} <span class="filter-query__assignee_value" id="${id}">${value}<span>` return `${f} ${token} <span class="filter-query__assignee_value" id="${id}">${value}<span>`
}) })
}) })
@ -149,17 +153,125 @@ const highlightedFilterQuery = computed(() => {
highlighted = highlighted.replaceAll(f, `<span class="filter-query__field">${f}</span>`) highlighted = highlighted.replaceAll(f, `<span class="filter-query__field">${f}</span>`)
}) })
return highlighted return highlighted
}) }
const currentOldDatepickerValue = ref('') const currentOldDatepickerValue = ref('')
const currentDatepickerValue = ref('') const currentDatepickerValue = ref('')
const currentDatepickerPos = ref() const currentDatepickerPos = ref()
const datePickerPopupOpen = ref(false) const datePickerPopupOpen = ref(false)
watch( function updateDateInQuery(newDate: string) {
() => highlightedFilterQuery.value, // Need to escape and unescape the query because the positions are based on the escaped query
async () => { let escaped = escapeHtml(filterQuery.value)
await nextTick() escaped = escaped
.substring(0, currentDatepickerPos.value)
+ escaped
.substring(currentDatepickerPos.value)
.replace(currentOldDatepickerValue.value, newDate)
currentOldDatepickerValue.value = newDate
filterQuery.value = unEscapeHtml(escaped)
updateQueryHighlight()
}
function getCharacterOffsetWithin(element: HTMLInputElement, isStart: boolean): number {
let range = document.createRange()
let sel = window.getSelection()
if (sel.rangeCount > 0) {
let originalRange = sel.getRangeAt(0)
range.selectNodeContents(element)
range.setEnd(
isStart ? originalRange.startContainer : originalRange.endContainer,
isStart ? originalRange.startOffset : originalRange.endOffset,
)
const rangeLength = range.toString().length
const originalLength = originalRange.toString().length
return rangeLength - (isStart ? 0 : originalLength)
}
return 0 // No selection
}
function saveSelectionOffsets(element: HTMLInputElement) {
return {
start: getCharacterOffsetWithin(element, true),
end: getCharacterOffsetWithin(element, false),
}
}
function setSelectionByCharacterOffsets(element: HTMLElement, startOffset: number, endOffset: number) {
let charIndex = 0, range = document.createRange()
const sel = window.getSelection()
console.log({startOffset, endOffset})
range.setStart(element, 0)
range.collapse(true)
let foundStart = false
const allTextNodes: ChildNode[] = []
element.childNodes.forEach(n => {
if (n.nodeType === Node.TEXT_NODE) {
allTextNodes.push(n)
}
n.childNodes.forEach(child => {
if (child.nodeType === Node.TEXT_NODE) {
allTextNodes.push(child)
}
})
})
allTextNodes.forEach(node => {
const nextCharIndex = charIndex + node.textContent.length
let addition = node.textContent === ' ' ? 1 : 0
if (!foundStart && startOffset >= charIndex && startOffset <= nextCharIndex) {
range.setStart(node, startOffset - charIndex + addition)
foundStart = true // Start position found
}
if (foundStart && endOffset >= charIndex && endOffset <= nextCharIndex) {
if (node.parentNode?.nodeName === 'BUTTON') {
node.parentNode?.focus()
range.setStartAfter(node.parentNode)
range.setEndAfter(node.parentNode)
return
}
range.setEnd(node, endOffset - charIndex + addition)
}
charIndex = nextCharIndex // Update charIndex to the next position
})
// FIXME: This kind of works for the first literal but breaks as soon as you type another query after the first it breaks
sel.removeAllRanges()
sel.addRange(range)
}
function updateQueryStringFromInput(e) {
filterQuery.value = e.target.innerText
const element = e.target
const offsets = saveSelectionOffsets(element)
if (offsets) {
updateQueryHighlight()
setSelectionByCharacterOffsets(element, offsets.start, offsets.end)
} else {
updateQueryHighlight()
}
}
const queryInputRef = ref<HTMLInputElement | null>(null)
function updateQueryHighlight() {
// Updating the query value in a function instead of a computed gives us more control about the timing
queryInputRef.value.innerHTML = getHighlightedFilterQuery()
nextTick(() => {
document.querySelectorAll('button.filter-query__date_value') document.querySelectorAll('button.filter-query__date_value')
.forEach(b => { .forEach(b => {
b.addEventListener('click', event => { b.addEventListener('click', event => {
@ -173,20 +285,7 @@ watch(
datePickerPopupOpen.value = true datePickerPopupOpen.value = true
}) })
}) })
}, })
{immediate: true}
)
function updateDateInQuery(newDate: string) {
// Need to escape and unescape the query because the positions are based on the escaped query
let escaped = escapeHtml(filterQuery.value)
escaped = escaped
.substring(0, currentDatepickerPos.value)
+ escaped
.substring(currentDatepickerPos.value)
.replace(currentOldDatepickerValue.value, newDate)
currentOldDatepickerValue.value = newDate
filterQuery.value = unEscapeHtml(escaped)
} }
</script> </script>
@ -194,19 +293,12 @@ function updateDateInQuery(newDate: string) {
<div class="field"> <div class="field">
<label class="label">{{ $t('filters.query.title') }}</label> <label class="label">{{ $t('filters.query.title') }}</label>
<div class="control filter-input"> <div class="control filter-input">
<textarea
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
v-model="filterQuery"
class="input"
ref="filterInput"
></textarea>
<div <div
class="filter-input-highlight" class="input filter-input-highlight"
:style="{'height': height}" :style="{'height': height}"
v-html="highlightedFilterQuery" contenteditable="true"
@input="updateQueryStringFromInput"
ref="queryInputRef"
></div> ></div>
<DatepickerWithValues <DatepickerWithValues
v-model="currentDatepickerValue" v-model="currentDatepickerValue"
@ -215,6 +307,7 @@ function updateDateInQuery(newDate: string) {
@update:model-value="updateDateInQuery" @update:model-value="updateDateInQuery"
/> />
</div> </div>
{{ filterQuery }}
</div> </div>
</template> </template>
@ -237,7 +330,7 @@ function updateDateInQuery(newDate: string) {
padding: .125rem .25rem; padding: .125rem .25rem;
display: inline-block; display: inline-block;
} }
&.filter-query__assignee_value { &.filter-query__assignee_value {
padding: .125rem .25rem; padding: .125rem .25rem;
border-radius: $radius; border-radius: $radius;
@ -245,7 +338,7 @@ function updateDateInQuery(newDate: string) {
color: var(--grey-700); color: var(--grey-700);
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
> img { > img {
margin-right: .25rem; margin-right: .25rem;
} }
@ -255,7 +348,6 @@ function updateDateInQuery(newDate: string) {
button.filter-query__date_value { button.filter-query__date_value {
padding: .125rem .25rem; padding: .125rem .25rem;
border-radius: $radius; border-radius: $radius;
position: absolute;
margin-top: calc((0.25em - 0.125rem) * -1); margin-top: calc((0.25em - 0.125rem) * -1);
height: 1.75rem; height: 1.75rem;
} }
@ -264,14 +356,14 @@ function updateDateInQuery(newDate: string) {
<style lang="scss" scoped> <style lang="scss" scoped>
.filter-input { .filter-input {
position: relative; //position: relative;
textarea { textarea {
position: absolute; //position: absolute;
text-fill-color: transparent; //text-fill-color: transparent;
-webkit-text-fill-color: transparent; //-webkit-text-fill-color: transparent;
background: transparent !important; //background: transparent !important;
resize: none; //resize: none;
} }
.filter-input-highlight { .filter-input-highlight {

View File

@ -33,6 +33,12 @@
<FilterInput v-model="filterQuery"/> <FilterInput v-model="filterQuery"/>
<Autocomplete
:options="filteredFruits"
suggestion="Type: Blueberry"
v-model="selectedValue"
/>
<div class="field"> <div class="field">
<label class="label">{{ $t('misc.search') }}</label> <label class="label">{{ $t('misc.search') }}</label>
<div class="control"> <div class="control">
@ -231,6 +237,116 @@ import ProjectService from '@/services/project'
// FIXME: do not use this here for now. instead create new version from DEFAULT_PARAMS // FIXME: do not use this here for now. instead create new version from DEFAULT_PARAMS
import {getDefaultParams} from '@/composables/useTaskList' import {getDefaultParams} from '@/composables/useTaskList'
import FilterInput from '@/components/project/partials/FilterInput.vue' import FilterInput from '@/components/project/partials/FilterInput.vue'
import Autocomplete from '@/components/input/Autocomplete.vue'
const selectedValue = ref('')
const filteredFruits = computed(() => {
const vals = (selectedValue.value || '').toLowerCase().split(' ')
return FRUITS
.filter(f => f.toLowerCase().startsWith(vals[vals.length - 1]))
.sort()
})
const FRUITS = [
'Apple',
'Apricot',
'Avocado',
'Banana',
'Bilberry',
'Blackberry',
'Blackcurrant',
'Blueberry',
'Boysenberry',
'Buddha\'s hand (fingered citron)',
'Crab apples',
'Currant',
'Cherry',
'Cherimoya',
'Chico fruit',
'Cloudberry',
'Coconut',
'Cranberry',
'Cucumber',
'Custard apple',
'Damson',
'Date',
'Dragonfruit',
'Durian',
'Elderberry',
'Feijoa',
'Fig',
'Goji berry',
'Gooseberry',
'Grape',
'Raisin',
'Grapefruit',
'Guava',
'Honeyberry',
'Huckleberry',
'Jabuticaba',
'Jackfruit',
'Jambul',
'Jujube',
'Juniper berry',
'Kiwano',
'Kiwifruit',
'Kumquat',
'Lemon',
'Lime',
'Loquat',
'Longan',
'Lychee',
'Mango',
'Mangosteen',
'Marionberry',
'Melon',
'Cantaloupe',
'Honeydew',
'Watermelon',
'Miracle fruit',
'Mulberry',
'Nectarine',
'Nance',
'Olive',
'Orange',
'Blood orange',
'Clementine',
'Mandarine',
'Tangerine',
'Papaya',
'Passionfruit',
'Peach',
'Pear',
'Persimmon',
'Plantain',
'Plum',
'Prune (dried plum)',
'Pineapple',
'Plumcot (or Pluot)',
'Pomegranate',
'Pomelo',
'Purple mangosteen',
'Quince',
'Raspberry',
'Salmonberry',
'Rambutan',
'Redcurrant',
'Salal berry',
'Salak',
'Satsuma',
'Soursop',
'Star fruit',
'gonzoberry',
'Strawberry',
'Tamarillo',
'Tamarind',
'Ugli fruit',
'Yuzu',
]
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {