feat(filters): move filter query to contenteditable
This commit is contained in:
parent
11bc4764de
commit
c058835874
|
@ -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(/</g, '<')
|
.replace(/</g, '<')
|
||||||
.replace(/>/g, '>')
|
.replace(/>/g, '>')
|
||||||
.replace(/"/g, '"')
|
.replace(/"/g, '"')
|
||||||
.replace(/'/g, "'")
|
.replace(/'/g, '\'')
|
||||||
}
|
}
|
||||||
|
|
||||||
const highlightedFilterQuery = computed(() => {
|
const TOKEN_REGEX = '(<|>|<=|>=|=|!=)'
|
||||||
|
|
||||||
|
function getHighlightedFilterQuery() {
|
||||||
let highlighted = escapeHtml(filterQuery.value)
|
let highlighted = escapeHtml(filterQuery.value)
|
||||||
dateFields
|
dateFields
|
||||||
.forEach(o => {
|
.forEach(o => {
|
||||||
const pattern = new RegExp(o + '\\s*(<|>|<=|>=|=|!=)\\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*(<|>|<=|>=|=|!=)\\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 {
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
Loading…
Reference in New Issue