Skip to content

Commit 790be0d

Browse files
e-zzhaydenull
andauthored
feat: shortcuts for agenda v3 (#297)
* feat: ctrl+click to jump to block * feat: shortcuts to quit, save and toggle a Task * feat(shortcuts): Add keyboard shortcuts for navigating and toggling views in the calendar - Double-tap [[ to go to the previous month in the calendar. - Double-tap ]] to go to the next month in the calendar. - Press W to toggle the calendar view between month and week. - Press T to toggle the app view between calendar and tasks. * fix: shortcuts triggered when typing text * feat: ctrl+click on TaskCard to jump to the block * fix: `ctrl+q` forces exiting Logseq. Use `ctrl+w` instead * refactor: use camel case naming --------- Co-authored-by: e-zz <> Co-authored-by: Hayden Chen <hayden.chen.dev@gmail.com>
1 parent 6c14162 commit 790be0d

File tree

5 files changed

+124
-5
lines changed

5 files changed

+124
-5
lines changed

src/Agenda3/components/MainArea.tsx

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { LeftOutlined, RightOutlined } from '@ant-design/icons'
22
import { Button, Segmented } from 'antd'
33
import dayjs from 'dayjs'
44
import { useAtom, useAtomValue } from 'jotai'
5-
import { useRef, useState } from 'react'
5+
import { useRef, useState, useEffect } from 'react'
66
import { useTranslation } from 'react-i18next'
77
import { FiSettings, FiXCircle } from 'react-icons/fi'
88
import { LuCalendarDays, LuKanbanSquare } from 'react-icons/lu'
@@ -15,7 +15,7 @@ import { cn } from '@/util/util'
1515
import Filter from './Filter'
1616
import UploadIcs from './UploadIcs'
1717
import Calendar, { type CalendarHandle } from './calendar/Calendar'
18-
import CalendarOperation, { type CalendarView } from './calendar/CalendarAdvancedOperation'
18+
import CalendarOperation, { CALENDAR_VIEWS, type CalendarView } from './calendar/CalendarAdvancedOperation'
1919
import KanBan, { type KanBanHandle } from './kanban/KanBan'
2020
import SettingsModal from './modals/SettingsModal'
2121

@@ -78,6 +78,62 @@ const MultipleView = ({ className }: { className?: string }) => {
7878
}))
7979
}
8080

81+
const setCalendarView = (view: CalendarView) => {
82+
calendarRef.current?.changeView(view as CalendarView)
83+
setApp((_app) => ({ ..._app, calendarView: view }))
84+
track('Calendar View Change', { calendarView: view })
85+
}
86+
87+
const togCalendarView = () => {
88+
const view =
89+
app.calendarView === CALENDAR_VIEWS.dayGridMonth ? CALENDAR_VIEWS.timeGridWeek : CALENDAR_VIEWS.dayGridMonth
90+
setCalendarView(view)
91+
}
92+
93+
useEffect(() => {
94+
let lastKeyDownTime = 0
95+
let lastKey = ''
96+
const doubleClickThreshold = 500 // 500 milliseconds
97+
98+
function handleKeyDown(event) {
99+
// Get the currently focused element
100+
const activeElement = document.activeElement
101+
102+
// If the focused element is an input or textarea, ignore the keydown event
103+
if (activeElement && (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA')) return
104+
105+
const calendarApi = calendarRef.current
106+
107+
const currentTime = new Date().getTime()
108+
109+
// Handle double-tap [[ or ]]
110+
if (currentTime - lastKeyDownTime <= doubleClickThreshold && lastKey === event.code) {
111+
// [[ : go to the previous month
112+
if (event.code === 'BracketLeft') calendarApi?.prev()
113+
// ]] : go to the next month
114+
if (event.code === 'BracketRight') calendarApi?.next()
115+
}
116+
117+
// Handle other keystrokes
118+
if (event.code === 'KeyW') togCalendarView()
119+
120+
if (event.code === 'KeyT') {
121+
const view = app.view === 'calendar' ? 'tasks' : 'calendar'
122+
onClickAppViewChange(view)
123+
// TODO UI: toggle the state of Calendar-Tasks slider accordingly
124+
}
125+
126+
lastKey = event.code
127+
lastKeyDownTime = currentTime
128+
}
129+
130+
document.addEventListener('keydown', handleKeyDown)
131+
132+
return () => {
133+
document.removeEventListener('keydown', handleKeyDown)
134+
}
135+
}, [app.calendarView, app.view, setApp])
136+
81137
return (
82138
<div className={cn('relative z-0 flex w-0 flex-1 flex-col py-1 pl-2', className)}>
83139
{/* ========= View Actions ========= */}

src/Agenda3/components/calendar/Calendar.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@ import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } f
1212
import { useTheme } from '@/Agenda3/components/ThemeProvider'
1313
import { genDurationString } from '@/Agenda3/helpers/block'
1414
import { transformAgendaTaskToCalendarEvent } from '@/Agenda3/helpers/fullCalendar'
15+
import { navToLogseqBlock } from '@/Agenda3/helpers/logseq'
1516
import { track } from '@/Agenda3/helpers/umami'
1617
import useAgendaEntities from '@/Agenda3/hooks/useAgendaEntities'
1718
import { appAtom } from '@/Agenda3/models/app'
1819
import { tasksWithStartAtom } from '@/Agenda3/models/entities/tasks'
20+
import { logseqAtom } from '@/Agenda3/models/logseq'
1921
import { settingsAtom } from '@/Agenda3/models/settings'
2022
// import useTheme from '@/hooks/useTheme'
2123
import type { AgendaTaskWithStart } from '@/types/task'
@@ -42,6 +44,7 @@ const Calendar = ({ onCalendarTitleChange }: CalendarProps, ref) => {
4244
const { updateEntity } = useAgendaEntities()
4345
const tasksWithStart = useAtomValue(tasksWithStartAtom)
4446
const settings = useAtomValue(settingsAtom)
47+
const { currentGraph } = useAtomValue(logseqAtom)
4548
const startingDay = settings.general?.startOfWeek
4649
const groupType = settings.selectedFilters?.length ? 'filter' : 'page'
4750
const showTasks = tasksWithStart?.filter((task) =>
@@ -80,6 +83,9 @@ const Calendar = ({ onCalendarTitleChange }: CalendarProps, ref) => {
8083
task: info.event.extendedProps as AgendaTaskWithStart,
8184
})
8285
}
86+
const onEventCtrlClick = (info: EventClickArg) => {
87+
navToLogseqBlock(info.event.extendedProps as AgendaTaskWithStart, currentGraph)
88+
}
8389
const onEventScheduleUpdate = (info: EventResizeDoneArg | EventReceiveArg | EventDropArg) => {
8490
// const calendarApi = calendarRef.current?.getApi()
8591
const { start, end, id: blockUUID, allDay, extendedProps } = info.event
@@ -232,7 +238,11 @@ const Calendar = ({ onCalendarTitleChange }: CalendarProps, ref) => {
232238
}}
233239
// click event
234240
eventClick={(info) => {
235-
onEventClick(info)
241+
if (info.jsEvent?.ctrlKey) {
242+
onEventCtrlClick(info)
243+
} else {
244+
onEventClick(info)
245+
}
236246
track('Calendar: Click Event', { calendarView: info.view.type })
237247
}}
238248
select={(info) => {

src/Agenda3/components/calendar/CalendarAdvancedOperation.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { IoMdCheckmark } from 'react-icons/io'
66
import useSettings from '@/Agenda3/hooks/useSettings'
77
import { cn } from '@/util/util'
88

9-
const CALENDAR_VIEWS = {
9+
export const CALENDAR_VIEWS = {
1010
dayGridMonth: 'dayGridMonth',
1111
timeGridWeek: 'timeGridWeek',
1212
} as const

src/Agenda3/components/kanban/taskCard/TaskCard.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import { RiDeleteBin4Line } from 'react-icons/ri'
66
import { VscDebugConsole } from 'react-icons/vsc'
77

88
import { minutesToHHmm } from '@/Agenda3/helpers/fullCalendar'
9+
import { navToLogseqBlock } from '@/Agenda3/helpers/logseq'
910
import useAgendaEntities from '@/Agenda3/hooks/useAgendaEntities'
11+
import { logseqAtom } from '@/Agenda3/models/logseq'
1012
import { settingsAtom } from '@/Agenda3/models/settings'
1113
import { DEFAULT_ESTIMATED_TIME } from '@/constants/agenda'
1214
import type { AgendaEntity } from '@/types/entity'
@@ -18,6 +20,7 @@ import TaskModal from '../../modals/TaskModal'
1820
import Toolbar from './Toolbar'
1921

2022
const TaskCard = ({ task }: { task: AgendaTaskWithStart }) => {
23+
const currentGraph = useAtomValue(logseqAtom).currentGraph
2124
const settings = useAtomValue(settingsAtom)
2225
const groupType = settings.selectedFilters?.length ? 'filter' : 'page'
2326

@@ -45,6 +48,15 @@ const TaskCard = ({ task }: { task: AgendaTaskWithStart }) => {
4548
const onRemoveDate = async (taskId: string) => {
4649
updateEntity({ type: 'task-remove-date', id: taskId, data: null })
4750
}
51+
const onClickTask = (e: React.MouseEvent, task: AgendaTaskWithStart) => {
52+
if (e.ctrlKey) {
53+
navToLogseqBlock(task, currentGraph)
54+
console.log(task)
55+
} else {
56+
setEditTaskModal({ open: true, task })
57+
}
58+
e.stopPropagation()
59+
}
4860

4961
return (
5062
<div
@@ -96,7 +108,11 @@ const TaskCard = ({ task }: { task: AgendaTaskWithStart }) => {
96108
},
97109
}}
98110
>
99-
<div onClick={() => setEditTaskModal({ open: true, task })}>
111+
<div
112+
onClick={(e) => {
113+
onClickTask(e, task)
114+
}}
115+
>
100116
{/* ========= Toolbar ========= */}
101117
<Toolbar task={task} groupType={groupType} onClickMark={onClickTaskMark} />
102118

src/Agenda3/components/modals/TaskModal/index.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import TimeSelect from '@/components/TaskModal/components/TimeSelect'
2121
import { SHOW_DATETIME_FORMATTER, SHOW_DATE_FORMATTER } from '@/constants/agenda'
2222
import type { AgendaEntity } from '@/types/entity'
2323
import type { AgendaTaskWithStart, TimeLog } from '@/types/task'
24+
import { getOS } from '@/util/util'
2425

2526
import ObjectiveSelect from '../../forms/ObjectiveSelect'
2627
import PageSelect from '../../forms/PageSelect'
@@ -151,6 +152,42 @@ const TaskModal = ({
151152
onCancel?.()
152153
setInternalOpen(false)
153154
}
155+
// Add keyboard event listener
156+
useEffect(() => {
157+
function handleKeyDown(event) {
158+
const isMac = getOS() === 'mac'
159+
const mainModifierKey = isMac ? event.metaKey : event.ctrlKey
160+
161+
if (event.code === 'KeyW' && mainModifierKey) {
162+
// Close the modal on pressing ctrl+q (or cmd+q on Mac)
163+
onCancel?.()
164+
setInternalOpen(false)
165+
event.stopPropagation()
166+
} else if (event.code === 'KeyS' && mainModifierKey) {
167+
// Save and close the modal on pressing ctrl+s (or cmd+s on Mac)
168+
onOk?.()
169+
setInternalOpen(false)
170+
event.stopPropagation()
171+
} else if (event.code === 'Enter' && mainModifierKey) {
172+
// toggle TODO status on pressing ctrl+Enter (or cmd+Enter on Mac)
173+
if (info.type === 'edit' && info.initialTaskData.status === 'done') {
174+
onSwitchTaskStatus('todo')
175+
}
176+
if (info.type === 'edit' && info.initialTaskData.status === 'todo') {
177+
onSwitchTaskStatus('done')
178+
setInternalOpen(false)
179+
}
180+
event.stopPropagation()
181+
}
182+
}
183+
184+
window.addEventListener('keydown', handleKeyDown)
185+
186+
return () => {
187+
window.removeEventListener('keydown', handleKeyDown)
188+
}
189+
}, [])
190+
154191
useEffect(() => {
155192
// 增加延时,否则二次打开无法自动聚焦
156193
if (_open) setTimeout(() => titleInputRef.current?.focus(), 0)

0 commit comments

Comments
 (0)