feat: add shortcut to quickly copy task identifier, title and url to clipboard (#2028)

This adds the following shortcuts:

- `.` to copy the task identifier
- `..` to copy the task identifier and title
- `...` to copy the task identifier, title, and url
- `Control + .` to copy the task url
This commit is contained in:
kolaente
2025-12-28 10:54:41 +01:00
committed by GitHub
parent 09eb1f5899
commit 6afb166dd2
4 changed files with 131 additions and 21 deletions

View File

@@ -209,6 +209,24 @@ export const KEYBOARD_SHORTCUTS: ShortcutGroup[] = [
title: 'keyboardShortcuts.task.save',
keys: [ctrl, 's'],
},
{
title: 'keyboardShortcuts.task.copyIdentifier',
keys: ['.'],
},
{
title: 'keyboardShortcuts.task.copyIdentifierAndTitle',
keys: ['.', '.'],
combination: 'then',
},
{
title: 'keyboardShortcuts.task.copyIdentifierTitleAndUrl',
keys: ['.', '.', '.'],
combination: 'then',
},
{
title: 'keyboardShortcuts.task.copyUrl',
keys: [ctrl, '.'],
},
],
},
] as const

View File

@@ -0,0 +1,100 @@
import {ref, onMounted, onBeforeUnmount} from 'vue'
import {eventToHotkeyString} from '@github/hotkey'
import {getTaskIdentifier} from '@/models/task'
import type {ITask} from '@/modelTypes/ITask'
interface UseTaskDetailShortcutsOptions {
task: () => ITask
taskTitle: () => string
onSave: () => void
}
async function copySavely(value: string) {
try {
await navigator.clipboard.writeText(value)
} catch(e) {
console.error('could not write to clipboard', e)
}
}
export function useTaskDetailShortcuts({
task,
taskTitle,
onSave,
}: UseTaskDetailShortcutsOptions) {
const dotKeyPressedTimes = ref(0)
const dotKeyCopyValue = ref('')
let dotKeyPressedTimeout: ReturnType<typeof setTimeout> | null = null
function resetDotKeyPressed() {
dotKeyPressedTimes.value = 0
dotKeyCopyValue.value = ''
if (dotKeyPressedTimeout !== null) {
clearTimeout(dotKeyPressedTimeout)
dotKeyPressedTimeout = null
}
}
// See https://github.com/github/hotkey/discussions/85#discussioncomment-5214660
async function handleTaskHotkey(event: KeyboardEvent) {
const hotkeyString = eventToHotkeyString(event)
if (!hotkeyString) return
if (hotkeyString === 'Control+s' || hotkeyString === 'Meta+s') {
event.preventDefault()
onSave()
return
}
const target = event.target as HTMLElement
if (
target.tagName.toLowerCase() === 'input' ||
target.tagName.toLowerCase() === 'textarea' ||
target.contentEditable === 'true'
) {
return
}
if (hotkeyString === 'Control+.') {
await copySavely(window.location.href)
return
}
if (hotkeyString === '.') {
dotKeyPressedTimes.value++
if (dotKeyPressedTimeout !== null) {
clearTimeout(dotKeyPressedTimeout)
}
dotKeyPressedTimeout = setTimeout(async () => {
await copySavely(dotKeyCopyValue.value)
resetDotKeyPressed()
}, 300)
switch (dotKeyPressedTimes.value) {
case 1:
dotKeyCopyValue.value = getTaskIdentifier(task())
break
case 2:
dotKeyCopyValue.value += ' - ' + taskTitle()
break
case 3:
dotKeyCopyValue.value += ' - ' + window.location.href
break
default:
resetDotKeyPressed()
}
}
}
onMounted(() => {
document.addEventListener('keydown', handleTaskHotkey)
})
onBeforeUnmount(() => {
document.removeEventListener('keydown', handleTaskHotkey)
resetDotKeyPressed()
})
}

View File

@@ -1117,7 +1117,11 @@
"priority": "Change the priority of this task",
"favorite": "Mark this task as favorite / unfavorite",
"openProject": "Open the project of this task",
"save": "Save the current task"
"save": "Save the current task",
"copyIdentifier": "Copy task identifier to clipboard",
"copyIdentifierAndTitle": "Copy task identifier and title to clipboard",
"copyIdentifierTitleAndUrl": "Copy task identifier, title and URL to clipboard",
"copyUrl": "Copy task URL to clipboard"
},
"project": {
"title": "Project Views",

View File

@@ -614,13 +614,12 @@
</template>
<script lang="ts" setup>
import {ref, reactive, shallowReactive, computed, watch, nextTick, onMounted, onBeforeUnmount} from 'vue'
import {ref, reactive, shallowReactive, computed, watch, nextTick, onMounted} from 'vue'
import {useRouter, useRoute, type RouteLocation, onBeforeRouteLeave} from 'vue-router'
import {storeToRefs} from 'pinia'
import {useI18n} from 'vue-i18n'
import {unrefElement, useDebounceFn, useElementSize, useIntersectionObserver, useMediaQuery, useMutationObserver} from '@vueuse/core'
import {klona} from 'klona/lite'
import {eventToHotkeyString} from '@github/hotkey'
import TaskService from '@/services/task'
import TaskModel from '@/models/task'
@@ -670,6 +669,7 @@ import {useAuthStore} from '@/stores/auth'
import {useBaseStore} from '@/stores/base'
import {useTitle} from '@/composables/useTitle'
import {useTaskDetailShortcuts} from '@/composables/useTaskDetailShortcuts'
import {success} from '@/message'
import type {Action as MessageAction} from '@/message'
@@ -700,16 +700,6 @@ const taskNotFound = ref(false)
const taskTitle = computed(() => task.value.title)
useTitle(taskTitle)
// See https://github.com/github/hotkey/discussions/85#discussioncomment-5214660
function saveTaskViaHotkey(event) {
const hotkeyString = eventToHotkeyString(event)
if (!hotkeyString) return
if (hotkeyString !== 'Control+s' && hotkeyString !== 'Meta+s') return
event.preventDefault()
saveTask()
}
const lastProject = computed(() => {
const backRoute = router.options.history.state?.back
if (!backRoute || typeof backRoute !== 'string') {
@@ -732,14 +722,6 @@ const lastProjectOrTaskProject = computed(() => lastProject.value ?? project.val
// Use Alt+r on other platforms
const reminderShortcut = computed(() => isAppleDevice() ? 'Shift+R' : 'Alt+r')
onMounted(() => {
document.addEventListener('keydown', saveTaskViaHotkey)
})
onBeforeUnmount(() => {
document.removeEventListener('keydown', saveTaskViaHotkey)
})
onBeforeRouteLeave(async () => {
if (taskNotFound.value) {
return
@@ -1054,6 +1036,12 @@ async function saveTask(
success({message: t('task.detail.updateSuccess')}, actions)
}
useTaskDetailShortcuts({
task: () => task.value,
taskTitle: () => taskTitle.value,
onSave: saveTask,
})
const showDeleteModal = ref(false)
async function deleteTask() {