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
No known key found for this signature in database
GPG Key ID: F40E70337AB24C9B
2 changed files with 263 additions and 55 deletions

View File

@ -1,10 +1,10 @@
<script setup lang="ts">
import {computed, nextTick, ref, watch} from 'vue'
import {nextTick, ref, watch} from 'vue'
import {useAutoHeightTextarea} from '@/composables/useAutoHeightTextarea'
import DatepickerWithValues from '@/components/date/datepickerWithValues.vue'
import UserService from "@/services/user";
import {getAvatarUrl, getDisplayName} from "@/models/user";
import {createRandomID} from "@/helpers/randomId";
import UserService from '@/services/user'
import {getAvatarUrl, getDisplayName} from '@/models/user'
import {createRandomID} from '@/helpers/randomId'
const {
modelValue,
@ -35,6 +35,7 @@ const dateFields = [
'doneAt',
'reminders',
]
const dateFieldsRegex = '(' + dateFields.join('|') + ')'
const assigneeFields = [
'assignees',
@ -84,45 +85,48 @@ function unEscapeHtml(unsafe: string): string {
.replace(/&lt;/g, '<')
.replace(/&gt;/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)
dateFields
.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) => {
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
.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) => {
if (typeof value === 'undefined') {
value = ''
}
const id = createRandomID(32)
userService.getAll({}, {s: value}).then(users => {
if (users.length > 0) {
const displayName = getDisplayName(users[0])
const nameTag = document.createElement('span')
nameTag.innerText = displayName
const avatar = document.createElement('img')
avatar.src = getAvatarUrl(users[0], 20)
avatar.height = 20
avatar.width = 20
avatar.alt = displayName
// TODO: caching
nextTick(() => {
const assigneeValue = document.getElementById(id)
assigneeValue.innerText = ''
@ -131,7 +135,7 @@ const highlightedFilterQuery = computed(() => {
})
}
})
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>`)
})
return highlighted
})
}
const currentOldDatepickerValue = ref('')
const currentDatepickerValue = ref('')
const currentDatepickerPos = ref()
const datePickerPopupOpen = ref(false)
watch(
() => highlightedFilterQuery.value,
async () => {
await nextTick()
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)
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')
.forEach(b => {
b.addEventListener('click', event => {
@ -173,20 +285,7 @@ watch(
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>
@ -194,19 +293,12 @@ function updateDateInQuery(newDate: string) {
<div class="field">
<label class="label">{{ $t('filters.query.title') }}</label>
<div class="control filter-input">
<textarea
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
v-model="filterQuery"
class="input"
ref="filterInput"
></textarea>
<div
class="filter-input-highlight"
class="input filter-input-highlight"
:style="{'height': height}"
v-html="highlightedFilterQuery"
contenteditable="true"
@input="updateQueryStringFromInput"
ref="queryInputRef"
></div>
<DatepickerWithValues
v-model="currentDatepickerValue"
@ -215,6 +307,7 @@ function updateDateInQuery(newDate: string) {
@update:model-value="updateDateInQuery"
/>
</div>
{{ filterQuery }}
</div>
</template>
@ -237,7 +330,7 @@ function updateDateInQuery(newDate: string) {
padding: .125rem .25rem;
display: inline-block;
}
&.filter-query__assignee_value {
padding: .125rem .25rem;
border-radius: $radius;
@ -245,7 +338,7 @@ function updateDateInQuery(newDate: string) {
color: var(--grey-700);
display: inline-flex;
align-items: center;
> img {
margin-right: .25rem;
}
@ -255,7 +348,6 @@ function updateDateInQuery(newDate: string) {
button.filter-query__date_value {
padding: .125rem .25rem;
border-radius: $radius;
position: absolute;
margin-top: calc((0.25em - 0.125rem) * -1);
height: 1.75rem;
}
@ -264,14 +356,14 @@ function updateDateInQuery(newDate: string) {
<style lang="scss" scoped>
.filter-input {
position: relative;
//position: relative;
textarea {
position: absolute;
text-fill-color: transparent;
-webkit-text-fill-color: transparent;
background: transparent !important;
resize: none;
//position: absolute;
//text-fill-color: transparent;
//-webkit-text-fill-color: transparent;
//background: transparent !important;
//resize: none;
}
.filter-input-highlight {

View File

@ -33,6 +33,12 @@
<FilterInput v-model="filterQuery"/>
<Autocomplete
:options="filteredFruits"
suggestion="Type: Blueberry"
v-model="selectedValue"
/>
<div class="field">
<label class="label">{{ $t('misc.search') }}</label>
<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
import {getDefaultParams} from '@/composables/useTaskList'
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({
modelValue: {