feat: harden textarea auto height algorithm (#985)

Co-authored-by: Dominik Pschenitschni <mail@celement.de>
Reviewed-on: https://kolaente.dev/vikunja/frontend/pulls/985
Reviewed-by: konrad <k@knt.li>
Co-authored-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
Co-committed-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
This commit is contained in:
Dominik Pschenitschni 2021-11-30 20:20:40 +00:00 committed by konrad
parent 716de2c99c
commit 84284a6211
2 changed files with 148 additions and 98 deletions

View file

@ -96,7 +96,8 @@
"env": { "env": {
"browser": true, "browser": true,
"es2021": true, "es2021": true,
"node": true "node": true,
"vue/setup-compiler-macros": true
}, },
"extends": [ "extends": [
"eslint:recommended", "eslint:recommended",
@ -120,6 +121,7 @@
"error", "error",
"never" "never"
], ],
"vue/script-setup-uses-vars": "error",
"vue/multi-word-component-names": 0 "vue/multi-word-component-names": 0
}, },
"parser": "vue-eslint-parser", "parser": "vue-eslint-parser",

View file

@ -3,17 +3,13 @@
<div class="field is-grouped"> <div class="field is-grouped">
<p class="control has-icons-left is-expanded"> <p class="control has-icons-left is-expanded">
<textarea <textarea
:disabled="taskService.loading || null" :disabled="taskService.loading || undefined"
class="input" class="add-task-textarea input"
:placeholder="$t('list.list.addPlaceholder')" :placeholder="$t('list.list.addPlaceholder')"
cols="1" rows="1"
v-focus v-focus
v-model="newTaskTitle" v-model="newTaskTitle"
ref="newTaskInput" ref="newTaskInput"
:style="{
'minHeight': `${initialTextAreaHeight}px`,
'height': `calc(${textAreaHeight}px - 2px + 1rem)`
}"
@keyup="errorMessage = ''" @keyup="errorMessage = ''"
@keydown.enter="handleEnter" @keydown.enter="handleEnter"
/> />
@ -23,7 +19,8 @@
</p> </p>
<p class="control"> <p class="control">
<x-button <x-button
:disabled="newTaskTitle === '' || taskService.loading || null" class="add-task-button"
:disabled="newTaskTitle === '' || taskService.loading || undefined"
@click="addTask()" @click="addTask()"
icon="plus" icon="plus"
:loading="taskService.loading" :loading="taskService.loading"
@ -35,94 +32,151 @@
<p class="help is-danger" v-if="errorMessage !== ''"> <p class="help is-danger" v-if="errorMessage !== ''">
{{ errorMessage }} {{ errorMessage }}
</p> </p>
<quick-add-magic v-if="errorMessage === ''"/> <quick-add-magic v-else />
</div> </div>
</template> </template>
<script> <script setup lang="ts">
import {ref, watch, unref, shallowReactive} from 'vue'
import {useI18n} from 'vue-i18n'
import {useStore} from 'vuex'
import { tryOnMounted, debouncedWatch, useWindowSize, MaybeRef } from '@vueuse/core'
import TaskService from '../../services/task' import TaskService from '../../services/task'
import QuickAddMagic from '@/components/tasks/partials/quick-add-magic.vue' import QuickAddMagic from '@/components/tasks/partials/quick-add-magic.vue'
const INPUT_BORDER_PX = 2 function cleanupTitle(title: string) {
const LINE_HEIGHT = 1.5 // using getComputedStyles().lineHeight returns an (wrong) absolute pixel value, we need the factor to do calculations with it.
const cleanupTitle = title => {
return title.replace(/^((\* |\+ |- )(\[ \] )?)/g, '') return title.replace(/^((\* |\+ |- )(\[ \] )?)/g, '')
} }
export default { function useAutoHeightTextarea(value: MaybeRef<string>) {
name: 'add-task', const textarea = ref<HTMLInputElement>()
emits: ['taskAdded'], const minHeight = ref(0)
data() {
return { // adapted from https://github.com/LeaVerou/stretchy/blob/47f5f065c733029acccb755cae793009645809e2/src/stretchy.js#L34
newTaskTitle: '', function resize(textareaEl: HTMLInputElement|undefined) {
taskService: new TaskService(), if (!textareaEl) return
errorMessage: '',
textAreaHeight: null, let empty
initialTextAreaHeight: null,
// the value here is the the attribute value
if (!textareaEl.value && textareaEl.placeholder) {
empty = true
textareaEl.value = textareaEl.placeholder
} }
const cs = getComputedStyle(textareaEl)
textareaEl.style.minHeight = ''
textareaEl.style.height = '0'
const offset = textareaEl.offsetHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom)
const height = textareaEl.scrollHeight + offset + 'px'
textareaEl.style.height = height
// calculate min-height for the first time
if (!minHeight.value) {
minHeight.value = parseFloat(height)
}
textareaEl.style.minHeight = minHeight.value.toString()
if (empty) {
textareaEl.value = ''
}
}
tryOnMounted(() => {
if (textarea.value) {
// we don't want scrollbars
textarea.value.style.overflowY = 'hidden'
}
})
const { width: windowWidth } = useWindowSize()
debouncedWatch(
windowWidth,
() => resize(textarea.value),
{ debounce: 200 },
)
// It is not possible to get notified of a change of the value attribute of a textarea without workarounds (setTimeout)
// So instead we watch the value that we bound to it.
watch(
() => [textarea.value, unref(value)],
() => resize(textarea.value),
{
immediate: true, // calculate initial size
flush: 'post', // resize after value change is rendered to DOM
}, },
components: { )
QuickAddMagic,
}, return textarea
props: { }
const emit = defineEmits(['taskAdded'])
const props = defineProps({
defaultPosition: { defaultPosition: {
type: Number, type: Number,
required: false, required: false,
}, },
}, })
watch: {
newTaskTitle(newVal) {
// Calculating the textarea height based on lines of input in it.
// That is more reliable when removing a line from the input.
const numberOfLines = newVal.split(/\r\n|\r|\n/).length
const fontSize = parseFloat(window.getComputedStyle(this.$refs.newTaskInput, null).getPropertyValue('font-size'))
this.textAreaHeight = numberOfLines * fontSize * LINE_HEIGHT + INPUT_BORDER_PX const taskService = shallowReactive(new TaskService())
}, const errorMessage = ref('')
},
mounted() { const newTaskTitle = ref('')
this.initialTextAreaHeight = this.$refs.newTaskInput.scrollHeight + INPUT_BORDER_PX const newTaskInput = useAutoHeightTextarea(newTaskTitle)
},
methods: { const { t } = useI18n()
async addTask() { const store = useStore()
if (this.newTaskTitle === '') {
this.errorMessage = this.$t('list.create.addTitleRequired') async function addTask() {
if (newTaskTitle.value === '') {
errorMessage.value = t('list.create.addTitleRequired')
return return
} }
this.errorMessage = '' errorMessage.value = ''
if (this.taskService.loading) { if (taskService.loading) {
return return
} }
const newTasks = this.newTaskTitle.split(/[\r\n]+/).map(async t => { const taskTitleBackup = newTaskTitle.value
const title = cleanupTitle(t) const newTasks = newTaskTitle.value.split(/[\r\n]+/).map(async uncleanedTitle => {
const title = cleanupTitle(uncleanedTitle)
if (title === '') { if (title === '') {
return return
} }
const task = await this.$store.dispatch('tasks/createNewTask', { const task = await store.dispatch('tasks/createNewTask', {
title, title,
listId: this.$store.state.auth.settings.defaultListId, listId: store.state.auth.settings.defaultListId,
position: this.defaultPosition, position: props.defaultPosition,
}) })
this.$emit('taskAdded', task) emit('taskAdded', task)
return task return task
}) })
try { try {
newTaskTitle.value = ''
await Promise.all(newTasks) await Promise.all(newTasks)
this.newTaskTitle = '' } catch (e: any) {
} catch (e) { newTaskTitle.value = taskTitleBackup
if (e.message === 'NO_LIST') { if (e?.message === 'NO_LIST') {
this.errorMessage = this.$t('list.create.addListRequired') errorMessage.value = t('list.create.addListRequired')
return return
} }
throw e throw e
} }
}, }
handleEnter(e) {
function handleEnter(e: KeyboardEvent) {
// when pressing shift + enter we want to continue as we normally would. Otherwise, we want to create // when pressing shift + enter we want to continue as we normally would. Otherwise, we want to create
// the new task(s). The vue event modifier don't allow this, hence this method. // the new task(s). The vue event modifier don't allow this, hence this method.
if (e.shiftKey) { if (e.shiftKey) {
@ -130,26 +184,20 @@ export default {
} }
e.preventDefault() e.preventDefault()
this.addTask() addTask()
},
},
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.task-add { .task-add {
margin-bottom: 0; margin-bottom: 0;
}
.button { .add-task-button {
height: 2.5rem; height: 2.5rem;
} }
} .add-task-textarea {
.input, .textarea {
transition: border-color $transition; transition: border-color $transition;
} resize: none;
.input {
resize: vertical;
} }
</style> </style>