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