diff --git a/package.json b/package.json index 1885d1dc..083ba4ef 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "test:frontend": "cypress run" }, "dependencies": { + "@kyvg/vue3-notification": "2.3.3", "@vue/compat": "3.2.14", "browserslist": "4.17.1", "bulma": "0.9.3", @@ -29,14 +30,13 @@ "snake-case": "3.0.4", "ufo": "0.7.9", "vue": "3.2.14", - "vue-advanced-cropper": "1.8.2", - "vue-drag-resize": "1.5.4", + "vue-advanced-cropper": "^2.6.3", + "vue-drag-resize": "^2.0.3", "vue-easymde": "1.4.0", - "vue-flatpickr-component": "9.0.4", + "vue-flatpickr-component": "9.0.5", "vue-i18n": "9.2.0-beta.6", "vue-router": "4.0.11", - "vue-shortkey": "3.1.7", - "vuedraggable": "2.24.3", + "vuedraggable": "4.0.1", "vuex": "4.0.2", "workbox-precaching": "6.3.0" }, @@ -70,7 +70,6 @@ "typescript": "4.4.3", "vite": "2.6.1", "vite-plugin-pwa": "0.11.2", - "vue-notification": "1.3.20", "wait-on": "6.0.0", "workbox-cli": "6.3.0" }, diff --git a/src/components/home/navigation.vue b/src/components/home/navigation.vue index db2a067d..a3f8bcc9 100644 --- a/src/components/home/navigation.vue +++ b/src/components/home/navigation.vue @@ -91,17 +91,20 @@ @end="e => saveListPosition(e, nk)" handle=".handle" :disabled="n.id < 0" - :class="{'dragging-disabled': n.id < 0}" + tag="transition-group" + item-key="id" + :component-data="{ + type: 'transition', + tag: 'ul', + name: !drag ? 'flip-list' : null, + class: [ + 'menu-list can-be-hidden', + { 'dragging-disabled': n.id < 0 } + ] + }" > - + diff --git a/src/components/tasks/gantt-component.vue b/src/components/tasks/gantt-component.vue index cf866c8f..f2e46718 100644 --- a/src/components/tasks/gantt-component.vue +++ b/src/components/tasks/gantt-component.vue @@ -86,9 +86,8 @@ :w="t.durationDays * dayWidth" :x="t.offsetDays * dayWidth - 6" :y="0" - @clicked="setTaskDragged(t)" - @dragstop="resizeTask" - @resizestop="resizeTask" + @dragstop="(e) => resizeTask(t, e)" + @resizestop="(e) => resizeTask(t, e)" axis="x" class="task" > @@ -136,9 +135,8 @@ :sticks="['mr', 'ml']" :x="dayOffsetUntilToday * dayWidth - 6" :y="0" - @clicked="setTaskDragged(t)" - @dragstop="resizeTask" - @resizestop="resizeTask" + @dragstop="(e) => resizeTask(t, e)" + @resizestop="(e) => resizeTask(t, e)" axis="x" class="task nodate" v-tooltip="$t('list.gantt.noDates')" @@ -233,7 +231,6 @@ export default { theTasks: [], // Pretty much a copy of the prop, since we cant mutate the prop directly tasksWithoutDates: [], taskService: new TaskService(), - taskDragged: null, // Saves to currently dragged task to be able to update it fullWidth: 0, now: new Date(), dayOffsetUntilToday: 0, @@ -361,15 +358,14 @@ export default { t.offsetDays = Math.floor((t.startDate - this.startDate) / 1000 / 60 / 60 / 24) return t }, - setTaskDragged(t) { - this.taskDragged = t - }, - resizeTask(newRect) { + resizeTask(taskDragged, newRect) { if (this.isTaskEdit) { return } - const didntHaveDates = this.taskDragged.startDate === null ? true : false + let newTask = { ...taskDragged } + + const didntHaveDates = newTask.startDate === null ? true : false let startDate = new Date(this.startDate) startDate.setDate( @@ -379,32 +375,32 @@ export default { startDate.setUTCMinutes(0) startDate.setUTCSeconds(0) startDate.setUTCMilliseconds(0) - this.taskDragged.startDate = startDate + newTask.startDate = startDate let endDate = new Date(startDate) endDate.setDate( startDate.getDate() + newRect.width / this.dayWidth, ) - this.taskDragged.startDate = startDate - this.taskDragged.endDate = endDate + newTask.startDate = startDate + newTask.endDate = endDate // We take the task from the overall tasks array because the one in it has bad data after it was updated once. // FIXME: This is a workaround. We should use a better mechanism to get the task or, even better, // prevent it from containing outdated Data in the first place. for (const tt in this.theTasks) { - if (this.theTasks[tt].id === this.taskDragged.id) { - this.taskDragged = this.theTasks[tt] + if (this.theTasks[tt].id === newTask.id) { + newTask = this.theTasks[tt] break } } const ganttData = { - endDate: this.taskDragged.endDate, - durationDays: this.taskDragged.durationDays, - offsetDays: this.taskDragged.offsetDays, + endDate: newTask.endDate, + durationDays: newTask.durationDays, + offsetDays: newTask.offsetDays, } this.taskService - .update(this.taskDragged) + .update(newTask) .then(r => { r.endDate = ganttData.endDate r.durationDays = ganttData.durationDays diff --git a/src/main.ts b/src/main.ts index 9fac1a48..cf0364c3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,11 @@ -import { createApp } from 'vue' +import { createApp, configureCompat } from 'vue' + +configureCompat({ + COMPONENT_V_MODEL: false, + COMPONENT_ASYNC: false, + RENDER_FUNCTION: false, + WATCH_ARRAY: false, // TODO: check this again; this might lead to some problemes +}) import App from './App.vue' import router from './router' @@ -18,13 +25,13 @@ import {VERSION} from './version.json' // Add CSS import './styles/vikunja.scss' // Notifications -import Notifications from 'vue-notification' +import Notifications from '@kyvg/vue3-notification' + // PWA import './registerServiceWorker' // Shortcuts -// @ts-ignore - no types available -import vueShortkey from 'vue-shortkey' +import shortkey from '@/plugins/shortkey' // Vuex import {store} from './store' // i18n @@ -45,19 +52,11 @@ if (window.API_URL.substr(window.API_URL.length - 1, window.API_URL.length) === const app = createApp(App) -Vue.use(Notifications) +app.use(Notifications) -Vue.use(vueShortkey, {prevent: ['input', 'textarea', '.input', '[contenteditable]']}) -app.config.globalProperties.$message = { - error(e, actions = []) { - return error(e, Vue.prototype, actions) - }, - success(s, actions = []) { - return success(s, Vue.prototype, actions) - }, -} +app.use(shortkey, {prevent: ['input', 'textarea', '.input', '[contenteditable]']}) // directives import focus from './directives/focus' @@ -92,6 +91,15 @@ app.mixin({ }, }) +app.config.errorHandler = (err, vm, info) => { + error(err) +} + +app.config.globalProperties.$message = { + error, + success, +} + app.use(router) app.use(store) app.use(i18n) diff --git a/src/message/index.js b/src/message/index.js index 40d9ec4f..0f2ad506 100644 --- a/src/message/index.js +++ b/src/message/index.js @@ -1,4 +1,5 @@ import {i18n} from '@/i18n' +import { notify } from '@kyvg/vue3-notification' export const getErrorText = (r) => { @@ -27,8 +28,8 @@ export const getErrorText = (r) => { return [r.message] } -export function error(e, context, actions = []) { - context.$notify({ +export function error(e, actions = []) { + notify({ type: 'error', title: i18n.global.t('error.error'), text: getErrorText(e), @@ -37,8 +38,8 @@ export function error(e, context, actions = []) { console.error(e, actions) } -export function success(e, context, actions = []) { - context.$notify({ +export function success(e, actions = []) { + notify({ type: 'success', title: i18n.global.t('error.success'), text: getErrorText(e), diff --git a/src/plugins/shortkey/helpers.js b/src/plugins/shortkey/helpers.js new file mode 100644 index 00000000..2baf329b --- /dev/null +++ b/src/plugins/shortkey/helpers.js @@ -0,0 +1,78 @@ +function capitalizeFirstLetter(string) { + return string.charAt(0).toUpperCase() + string.slice(1) +} + +const MODIFIER_KEYS = ['shift', 'ctrl', 'meta', 'alt'] + +const SHORT_CUT_INDEX = [ + { key: 'ArrowUp', value: 'arrowup' }, + { key: 'ArrowLeft', value: 'arrowlef' }, + { key: 'ArrowRight', value: 'arrowright' }, + { key: 'ArrowDown', value: 'arrowdown' }, + { key: 'AltGraph', value: 'altgraph' }, + { key: 'Escape', value: 'esc' }, + { key: 'Enter', value: 'enter' }, + { key: 'Tab', value: 'tab' }, + { key: ' ', value: 'space' }, + { key: 'PageUp', value: 'pagup' }, + { key: 'PageDown', value: 'pagedow' }, + { key: 'Home', value: 'home' }, + { key: 'End', value: 'end' }, + { key: 'Delete', value: 'del' }, + { key: 'Backspace', value: 'bacspace' }, + { key: 'Insert', value: 'insert' }, + { key: 'NumLock', value: 'numlock' }, + { key: 'CapsLock', value: 'capslock' }, + { key: 'Pause', value: 'pause' }, + { key: 'ContextMenu', value: 'cotextmenu' }, + { key: 'ScrollLock', value: 'scrolllock' }, + { key: 'BrowserHome', value: 'browserhome' }, + { key: 'MediaSelect', value: 'mediaselect' }, +] + +export function encodeKey(pKey) { + const shortKey = {} + + MODIFIER_KEYS.forEach((key) => { + shortKey[`${key}Key`] = pKey.includes(key) + }) + + let indexedKeys = createShortcutIndex(shortKey) + const vKey = pKey.filter( + (item) => !MODIFIER_KEYS.includes(item), + ) + indexedKeys += vKey.join('') + return indexedKeys +} + +function createShortcutIndex(pKey) { + let k = '' + + MODIFIER_KEYS.forEach((key) => { + if (pKey.key === capitalizeFirstLetter(key) || pKey[`${key}Key`]) { + k += key + } + }) + + SHORT_CUT_INDEX.forEach(({ key, value }) => { + if (pKey.key === key) { + k += value + } + }) + + if ( + (pKey.key && pKey.key !== ' ' && pKey.key.length === 1) || + /F\d{1,2}|\//g.test(pKey.key) + ) { + k += pKey.key.toLowerCase() + } + + return k +} +export { createShortcutIndex as decodeKey } + +export function parseValue(value) { + value = typeof value === 'string' ? JSON.parse(value.replace(/'/gi, '"')) : value + + return value instanceof Array ? { '': value } : value +} diff --git a/src/plugins/shortkey/index.js b/src/plugins/shortkey/index.js new file mode 100644 index 00000000..8755fd92 --- /dev/null +++ b/src/plugins/shortkey/index.js @@ -0,0 +1,186 @@ +import { parseValue, decodeKey, encodeKey } from './helpers' + +let mapFunctions = {} +let objAvoided = [] +let elementAvoided = [] +let keyPressed = false + +function dispatchShortkeyEvent(pKey) { + const e = new CustomEvent('shortkey', { bubbles: false }) + + if (mapFunctions[pKey].key) { + e.srcKey = mapFunctions[pKey].key + } + + const elm = mapFunctions[pKey].el + + if (!mapFunctions[pKey].propagte) { + elm[elm.length - 1].dispatchEvent(e) + } else { + elm.forEach((elmItem) => elmItem.dispatchEvent(e)) + } +} + +function keyDown(pKey) { + if ( + (!mapFunctions[pKey].once && !mapFunctions[pKey].push) || + (mapFunctions[pKey].push && !keyPressed) + ) { + dispatchShortkeyEvent(pKey) + } +} + +function fillMappingFunctions( + mappingFunctions, + { b, push, once, focus, propagte, el }, +) { + for (let key in b) { + const k = encodeKey(b[key]) + const propagated = mappingFunctions[k] && mappingFunctions[k].propagte + const elm = + mappingFunctions[k] && mappingFunctions[k].el + ? mappingFunctions[k].el + : [] + + elm.push(el) + + mappingFunctions[k] = { + push, + once, + focus, + key, + propagte: propagated || propagte, + el: elm, + } + } +} + +function bindValue(value, el, binding, vnode) { + const { modifiers } = binding + const push = !!modifiers.push + const avoid = !!modifiers.avoid + const focus = !modifiers.focus + const once = !!modifiers.once + const propagte = !!modifiers.propagte + + if (avoid) { + objAvoided = objAvoided.filter((itm) => !itm === el) + objAvoided.push(el) + } else { + fillMappingFunctions(mapFunctions, { + b: value, + push, + once, + focus, + propagte, + el: vnode.el, + }) + } +} + +function unbindValue(value, el) { + for (let key in value) { + const k = encodeKey(value[key]) + const idxElm = mapFunctions[k].el.indexOf(el) + + if (mapFunctions[k].el.length > 1 && idxElm > -1) { + mapFunctions[k].el.splice(idxElm, 1) + } else { + delete mapFunctions[k] + } + } +} + +function availableElement(decodedKey) { + const objectIsAvoided = !!objAvoided.find( + (r) => r === document.activeElement, + ) + const filterAvoided = !!elementAvoided.find( + (selector) => + document.activeElement && document.activeElement.matches(selector), + ) + return !!mapFunctions[decodedKey] && !(objectIsAvoided || filterAvoided) +} + +function keyDownListener(pKey) { + const decodedKey = decodeKey(pKey) + + // Check avoidable elements + if (!availableElement(decodedKey)) { + return + } + + if (!mapFunctions[decodedKey].propagte) { + pKey.preventDefault() + pKey.stopPropagation() + } + + if (mapFunctions[decodedKey].focus) { + keyDown(decodedKey) + keyPressed = true + } else if (!keyPressed) { + const elm = mapFunctions[decodedKey].el + elm[elm.length - 1].focus() + keyPressed = true + } +} + +function keyUpListener(pKey) { + const decodedKey = decodeKey(pKey) + + if (!availableElement(decodedKey)) { + keyPressed = false + return + } + + if (!mapFunctions[decodedKey].propagte) { + pKey.preventDefault() + pKey.stopPropagation() + } + + if (mapFunctions[decodedKey].once || mapFunctions[decodedKey].push) { + dispatchShortkeyEvent(decodedKey) + } + + keyPressed = false +} + +// register key presses that happen before mounting of directive +// if (process?.env?.NODE_ENV !== 'test') { +// (() => { + document.addEventListener('keydown', keyDownListener, true) + document.addEventListener('keyup', keyUpListener, true) + // })() +// } + +function install(app, options) { + elementAvoided = [...(options && options.prevent ? options.prevent : [])] + + app.directive('shortkey', { + beforeMount(el, binding, vnode) { + // Mapping the commands + const value = parseValue(binding.value) + bindValue(value, el, binding, vnode) + }, + + updated(el, binding, vnode) { + const oldValue = parseValue(binding.oldValue) + unbindValue(oldValue, el) + + const newValue = parseValue(binding.value) + bindValue(newValue, el, binding, vnode) + }, + + unmounted(el, binding) { + const value = parseValue(binding.value) + unbindValue(value, el) + }, + }) +} + +export default { + install, + encodeKey, + decodeKey, + keyDown, +} diff --git a/src/views/list/views/Kanban.vue b/src/views/list/views/Kanban.vue index 7e9d5f98..6c7708d8 100644 --- a/src/views/list/views/Kanban.vue +++ b/src/views/list/views/Kanban.vue @@ -27,17 +27,14 @@ group="buckets" :disabled="!canWrite" :class="{'dragging-disabled': !canWrite}" + tag="transition-group" + :item-key="({id}) => `bucket${id}`" + :component-data="bucketDraggableComponentData" > - +
@@ -251,6 +247,15 @@ import {getCollapsedBucketState, saveCollapsedBucketState} from '@/helpers/saveC import {calculateItemPosition} from '../../../helpers/calculateItemPosition' import KanbanCard from '../../../components/tasks/partials/kanban-card' +const DRAG_OPTIONS = { + // sortable options + animation: 150, + ghostClass: 'ghost', + dragClass: 'task-dragging', + delayOnTouchOnly: true, + delay: 150, +} + export default { name: 'Kanban', components: { @@ -261,6 +266,8 @@ export default { }, data() { return { + dragOptions: DRAG_OPTIONS, + drag: false, dragBucket: false, sourceBucket: 0, @@ -305,6 +312,21 @@ export default { '$route.params.listId': 'loadBuckets', }, computed: { + bucketDraggableComponentData() { + return { + type: 'transition', + tag: 'div', + name: !this.dragBucket ? 'move-bucket': null, + class: 'kanban-bucket-container', + } + }, + taskDraggableTaskComponentData() { + return { + type: 'transition', + tag: 'div', + name: !this.drag ? 'move-card': null, + } + }, buckets: { get() { return this.$store.state.kanban.buckets @@ -313,17 +335,6 @@ export default { this.$store.commit('kanban/setBuckets', value) }, }, - dragOptions() { - const options = { - animation: 150, - ghostClass: 'ghost', - dragClass: 'task-dragging', - delay: 150, - delayOnTouchOnly: true, - } - - return options - }, ...mapState({ loadedListId: state => state.kanban.listId, loading: state => state[LOADING] && state[LOADING_MODULE] === 'kanban', diff --git a/src/views/list/views/List.vue b/src/views/list/views/List.vue index 21744029..741e65d3 100644 --- a/src/views/list/views/List.vue +++ b/src/views/list/views/List.vue @@ -89,27 +89,28 @@ handle=".handle" :disabled="!canWrite" :class="{'dragging-disabled': !canWrite}" + item-key="id" > - - - - -
+ - -
-
+ + + +
+ +
+ +