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"
>
-
-
-
-
-
+
-
-
-
+
+
+
+
+
+
+
+