feat(frontend): adapt QuickActions for quick-add mode behavior

This commit is contained in:
kolaente 2026-03-03 00:03:20 +01:00 committed by kolaente
parent 9072ca84d5
commit 8dc96d61bd
4 changed files with 251 additions and 109 deletions

View File

@ -4,7 +4,12 @@
:overflow="isNewTaskCommand"
@close="closeQuickActions"
>
<div class="card quick-actions">
<div
ref="quickActionsCard"
class="card quick-actions"
:class="{'is-quick-add-mode': isQuickAddMode}"
:style="isQuickAddMode ? {maxHeight: quickEntryMaxHeight + 'px', overflowY: 'auto'} : undefined"
>
<div
class="action-input"
:class="{'has-active-cmd': selectedCmd !== null}"
@ -28,6 +33,9 @@
@keyup.prevent.enter="doCmd"
@keyup.prevent.esc="closeQuickActions"
>
<QuickAddMagic
v-if="isNewTaskCommand"
/>
<BaseButton
class="close"
@click="closeQuickActions"
@ -43,8 +51,6 @@
{{ hintText }}
</div>
<QuickAddMagic v-if="isNewTaskCommand" />
<div
v-if="selectedCmd === null"
class="results"
@ -97,7 +103,8 @@
</template>
<script setup lang="ts">
import {type ComponentPublicInstance, computed, ref, shallowReactive, watchEffect} from 'vue'
import {type ComponentPublicInstance, computed, ref, shallowReactive, watch, watchEffect, onBeforeUnmount} from 'vue'
import {useQuickAddMode} from '@/composables/useQuickAddMode'
import {useI18n} from 'vue-i18n'
import {useRouter} from 'vue-router'
@ -137,6 +144,8 @@ const labelStore = useLabelStore()
const taskStore = useTaskStore()
const authStore = useAuthStore()
const {isQuickAddMode} = useQuickAddMode()
type DoAction<Type> = { type: ACTION_TYPE } & Type
enum ACTION_TYPE {
@ -177,6 +186,37 @@ watchEffect(() => {
}
})
let focusRafId: number | null = null
watchEffect(() => {
if (active.value && isQuickAddMode) {
selectedCmd.value = commands.value.newTask
// The input may not be focusable yet due to:
// 1. Modal transition (v-if + <Transition appear>) delaying DOM readiness
// 2. Electron window not yet visible (shown after did-finish-load)
// Retry with rAF until focus actually lands on the input.
const tryFocus = () => {
if (!active.value) {
focusRafId = null
return
}
if (searchInput.value) {
searchInput.value.focus()
if (document.activeElement === searchInput.value) {
focusRafId = null
return
}
}
focusRafId = requestAnimationFrame(tryFocus)
}
focusRafId = requestAnimationFrame(tryFocus)
} else if (focusRafId !== null) {
cancelAnimationFrame(focusRafId)
focusRafId = null
}
})
function closeQuickActions() {
baseStore.setQuickActionsActive(false)
}
@ -297,7 +337,7 @@ const currentProject = computed(() => {
if (Object.keys(baseStore.currentProject).length === 0 || isSavedFilter(baseStore.currentProject)) {
return null
}
return baseStore.currentProject
})
@ -454,29 +494,66 @@ function search() {
}
const searchInput = ref<HTMLElement | null>(null)
const quickActionsCard = ref<HTMLElement | null>(null)
const QUICK_ENTRY_WIDTH = 680
const quickEntryMaxHeight = Math.round(window.screen.availHeight * 0.7)
let resizeObserver: ResizeObserver | null = null
if (isQuickAddMode) {
watch(quickActionsCard, (el) => {
resizeObserver?.disconnect()
if (!el) return
resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const height = Math.min(
Math.ceil(entry.borderBoxSize[0].blockSize),
quickEntryMaxHeight,
)
window.quickEntry?.resize?.(QUICK_ENTRY_WIDTH, height)
}
})
resizeObserver.observe(el)
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
})
}
async function doAction(type: ACTION_TYPE, item: DoAction) {
switch (type) {
case ACTION_TYPE.PROJECT:
closeQuickActions()
await router.push({
name: 'project.index',
params: {projectId: (item as DoAction<IProject>).id},
})
if (!isQuickAddMode) {
await router.push({
name: 'project.index',
params: {projectId: (item as DoAction<IProject>).id},
})
}
break
case ACTION_TYPE.TASK:
if (isQuickAddMode) {
const channel = new BroadcastChannel('vikunja-task-updates')
channel.postMessage({type: 'task-created-open', taskId: (item as DoAction<ITask>).id})
channel.close()
window.quickEntry?.showMainWindow()
} else {
await router.push({
name: 'task.detail',
params: {id: (item as DoAction<ITask>).id},
})
}
closeQuickActions()
await router.push({
name: 'task.detail',
params: {id: (item as DoAction<ITask>).id},
})
break
case ACTION_TYPE.TEAM:
closeQuickActions()
await router.push({
name: 'teams.edit',
params: {id: (item as DoAction<ITeam>).id},
})
if (!isQuickAddMode) {
await router.push({
name: 'teams.edit',
params: {id: (item as DoAction<ITeam>).id},
})
}
break
case ACTION_TYPE.CMD:
query.value = ''
@ -506,7 +583,9 @@ async function doCmd() {
return
}
closeQuickActions()
if (!isQuickAddMode) {
closeQuickActions()
}
await selectedCmd.value.action()
}
@ -520,6 +599,15 @@ async function newTask() {
projectId,
})
success({message: t('task.createSuccess')})
if (isQuickAddMode) {
const channel = new BroadcastChannel('vikunja-task-updates')
channel.postMessage({type: 'task-created', taskId: task.id})
channel.close()
closeQuickActions()
return
}
await router.push({name: 'task.detail', params: {id: task.id}})
}
@ -602,6 +690,13 @@ function reset() {
inset-block-start: 3rem;
transform: translate(-50%, 0);
}
&.is-quick-add-mode {
padding: 0;
margin: 0;
border: none;
box-shadow: none;
}
}
.action-input {
@ -611,7 +706,7 @@ function reset() {
.input {
border: 0;
font-size: 1.5rem;
@media screen and (max-width: $tablet) {
padding-inline-end: .25rem;
}
@ -624,7 +719,7 @@ function reset() {
.close {
padding: 0 1rem 0 .5rem;
font-size: 1.5rem;
@media screen and (min-width: $tablet + 1) {
display: none;
}
@ -675,14 +770,14 @@ function reset() {
&:active {
background: var(--grey-100);
}
.saved-filter-icon {
font-size: .75rem;
inline-size: .75rem;
margin-inline-end: .25rem;
color: var(--grey-400)
}
&:has(.saved-filter-icon) {
display: inline-flex;
align-items: center;

View File

@ -33,5 +33,25 @@ watch(() => baseStore.quickActionsActive, (active) => {
display: flex;
align-items: flex-start;
justify-content: center;
overflow: hidden;
}
</style>
<style lang="scss">
// In quick-add mode the Electron window IS the overlay hide the modal
// backdrop, disable scroll, and collapse all extra spacing so the input
// fills the window edge-to-edge.
.quick-add-overlay {
.modal-mask {
background: transparent;
}
.modal-container {
overflow: hidden;
}
.modal-mask > .close {
display: none;
}
}
</style>

View File

@ -1,101 +1,110 @@
<template>
<template v-if="mode !== 'disabled' && prefixes !== undefined">
<BaseButton
v-tooltip="$t('task.quickAddMagic.hint')"
class="icon is-small show-helper-text quick-add-magic-trigger-btn"
:aria-label="$t('task.quickAddMagic.hint')"
:class="{'is-highlighted': highlightHintIcon}"
@click="() => visible = true"
<span
v-if="isQuickAddMode"
v-tooltip="$t('task.quickAddMagic.quickEntryHint')"
class="icon is-small show-helper-text"
>
<Icon :icon="['far', 'circle-question']" />
</BaseButton>
<Modal
:enabled="visible"
transition-name="fade"
:overflow="true"
variant="hint-modal"
@close="() => visible = false"
>
<Card
class="has-no-shadow"
:title="$t('task.quickAddMagic.title')"
:show-close="true"
@close="() => visible = false"
</span>
<template v-else>
<BaseButton
v-tooltip="$t('task.quickAddMagic.hint')"
class="icon is-small show-helper-text quick-add-magic-trigger-btn"
:aria-label="$t('task.quickAddMagic.hint')"
:class="{'is-highlighted': highlightHintIcon}"
@click="open"
>
<p>{{ $t('task.quickAddMagic.intro') }}</p>
<Icon :icon="['far', 'circle-question']" />
</BaseButton>
<Modal
:enabled="visible"
transition-name="fade"
:overflow="true"
variant="hint-modal"
@close="close"
>
<Card
class="has-no-shadow"
:title="$t('task.quickAddMagic.title')"
:show-close="true"
@close="close"
>
<p>{{ $t('task.quickAddMagic.intro') }}</p>
<h3>{{ $t('task.attributes.labels') }}</h3>
<p>
{{ $t('task.quickAddMagic.label1', {prefix: prefixes.label}) }}
{{ $t('task.quickAddMagic.label2') }}
{{ $t('task.quickAddMagic.multiple') }}
</p>
<p>
{{ $t('task.quickAddMagic.label3') }}
{{ $t('task.quickAddMagic.label4', {prefix: prefixes.label}) }}
</p>
<h3>{{ $t('task.attributes.labels') }}</h3>
<p>
{{ $t('task.quickAddMagic.label1', {prefix: prefixes.label}) }}
{{ $t('task.quickAddMagic.label2') }}
{{ $t('task.quickAddMagic.multiple') }}
</p>
<p>
{{ $t('task.quickAddMagic.label3') }}
{{ $t('task.quickAddMagic.label4', {prefix: prefixes.label}) }}
</p>
<h3>{{ $t('task.attributes.priority') }}</h3>
<p>
{{ $t('task.quickAddMagic.priority1', {prefix: prefixes.priority}) }}
{{ $t('task.quickAddMagic.priority2') }}
</p>
<h3>{{ $t('task.attributes.priority') }}</h3>
<p>
{{ $t('task.quickAddMagic.priority1', {prefix: prefixes.priority}) }}
{{ $t('task.quickAddMagic.priority2') }}
</p>
<h3>{{ $t('task.attributes.assignees') }}</h3>
<p>
{{ $t('task.quickAddMagic.assignees', {prefix: prefixes.assignee}) }}
{{ $t('task.quickAddMagic.multiple') }}
</p>
<h3>{{ $t('task.attributes.assignees') }}</h3>
<p>
{{ $t('task.quickAddMagic.assignees', {prefix: prefixes.assignee}) }}
{{ $t('task.quickAddMagic.multiple') }}
</p>
<h3>{{ $t('quickActions.projects') }}</h3>
<p>
{{ $t('task.quickAddMagic.project1', {prefix: prefixes.project}) }}
{{ $t('task.quickAddMagic.project2') }}
</p>
<p>
{{ $t('task.quickAddMagic.project3') }}
{{ $t('task.quickAddMagic.project4', {prefix: prefixes.project}) }}
</p>
<h3>{{ $t('quickActions.projects') }}</h3>
<p>
{{ $t('task.quickAddMagic.project1', {prefix: prefixes.project}) }}
{{ $t('task.quickAddMagic.project2') }}
</p>
<p>
{{ $t('task.quickAddMagic.project3') }}
{{ $t('task.quickAddMagic.project4', {prefix: prefixes.project}) }}
</p>
<h3>{{ $t('task.quickAddMagic.dateAndTime') }}</h3>
<p>
{{ $t('task.quickAddMagic.date') }}
</p>
<ul>
<!-- Not localized because these only work in english -->
<li>Today</li>
<li>Tonight</li>
<li>Tomorrow</li>
<li>Next monday</li>
<li>This weekend</li>
<li>Later this week</li>
<li>Later next week</li>
<li>Next week</li>
<li>Next month</li>
<li>End of month</li>
<li>In 5 days [hours/weeks/months]</li>
<li>Tuesday ({{ $t('task.quickAddMagic.dateWeekday') }})</li>
<li>02/17/2021</li>
<li>2021-02-17</li>
<li>17.02.2021</li>
<li>Feb 17 ({{ $t('task.quickAddMagic.dateCurrentYear') }})</li>
<li>17th ({{ $t('task.quickAddMagic.dateNth', {day: '17'}) }})</li>
</ul>
<p>{{ $t('task.quickAddMagic.dateTime', {time: 'at 17:00', timePM: '5pm'}) }}</p>
<h3>{{ $t('task.quickAddMagic.dateAndTime') }}</h3>
<p>
{{ $t('task.quickAddMagic.date') }}
</p>
<ul>
<!-- Not localized because these only work in english -->
<li>Today</li>
<li>Tonight</li>
<li>Tomorrow</li>
<li>Next monday</li>
<li>This weekend</li>
<li>Later this week</li>
<li>Later next week</li>
<li>Next week</li>
<li>Next month</li>
<li>End of month</li>
<li>In 5 days [hours/weeks/months]</li>
<li>Tuesday ({{ $t('task.quickAddMagic.dateWeekday') }})</li>
<li>02/17/2021</li>
<li>2021-02-17</li>
<li>17.02.2021</li>
<li>Feb 17 ({{ $t('task.quickAddMagic.dateCurrentYear') }})</li>
<li>17th ({{ $t('task.quickAddMagic.dateNth', {day: '17'}) }})</li>
</ul>
<p>{{ $t('task.quickAddMagic.dateTime', {time: 'at 17:00', timePM: '5pm'}) }}</p>
<h3>{{ $t('task.quickAddMagic.repeats') }}</h3>
<p>{{ $t('task.quickAddMagic.repeatsDescription', {suffix: 'every {amount} {type}'}) }}</p>
<p>{{ $t('misc.forExample') }}</p>
<ul>
<!-- Not localized because these only work in english -->
<li>Every day</li>
<li>Every 3 days</li>
<li>Every week</li>
<li>Every 2 weeks</li>
<li>Every month</li>
</ul>
</Card>
</Modal>
<h3>{{ $t('task.quickAddMagic.repeats') }}</h3>
<p>{{ $t('task.quickAddMagic.repeatsDescription', {suffix: 'every {amount} {type}'}) }}</p>
<p>{{ $t('misc.forExample') }}</p>
<ul>
<!-- Not localized because these only work in english -->
<li>Every day</li>
<li>Every 3 days</li>
<li>Every week</li>
<li>Every 2 weeks</li>
<li>Every month</li>
</ul>
</Card>
</Modal>
</template>
</template>
</template>
@ -106,17 +115,34 @@ import BaseButton from '@/components/base/BaseButton.vue'
import {PREFIXES} from '@/modules/quickAddMagic'
import {useAuthStore} from '@/stores/auth'
import {useQuickAddMode} from '@/composables/useQuickAddMode'
defineProps<{
highlightHintIcon?: boolean,
}>()
const emit = defineEmits<{
opened: []
closed: []
}>()
const authStore = useAuthStore()
const {isQuickAddMode} = useQuickAddMode()
const visible = ref(false)
const mode = computed(() => authStore.settings.frontendSettings.quickAddMagicMode)
const prefixes = computed(() => PREFIXES[mode.value])
function open() {
visible.value = true
emit('opened')
}
function close() {
visible.value = false
emit('closed')
}
</script>
<style lang="scss" scoped>

View File

@ -1067,6 +1067,7 @@
},
"quickAddMagic": {
"hint": "Use magic prefixes to define due dates, assignees and other task properties.",
"quickEntryHint": "Use magic prefixes for dates, labels & more. Open the main Vikunja app and check the tooltip on the task input for more details.",
"title": "Quick Add Magic",
"intro": "When creating a task, you can use special keywords to directly add attributes to the newly created task. This allows to add commonly used attributes to tasks much faster.",
"multiple": "You can use this multiple times.",