From 84b7bb2b2ff4ea03b5ef7d6582b48a57859b3301 Mon Sep 17 00:00:00 2001 From: Truong Trung Nghia <149591043+ttncode@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:00:59 +0700 Subject: [PATCH] feat: shortcuts for every screen-level control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Every control that appears once per screen now has a shortcut (rebindable in Ctrl+/, hinted on hover). Row-level buttons in lists stay reachable with Tab + Enter. | Screen | Control | Default | | --- | --- | --- | | Focus | Reset session | Alt+R | | Focus | Extend finished session | Alt+Shift+E | | Focus | Edit minutes | Alt+I | | Focus | Auto-shuffle animations on/off | Alt+Shift+A | | Focus | Next animation | Alt+Shift+C | | To-Dos / Habits / Goals / Tags | Show / hide archived | Alt+Shift+H | | To-Dos | Show / hide backlog | Alt+Shift+K | | Habits | Switch 7 / 30 days | Alt+Shift+D | | Habits, Focus summary | Previous / next period | Alt+Shift+← / → | | Focus summary | Next chart range (7→30→90→180→365 days) | Alt+Shift+P | | Boost | Public / private post | Alt+Shift+V | | Tags | Back | Alt+Shift+B | | About Me | Save | Ctrl+S | | Menu | Sounds / Notifications | Alt+Shift+S / Alt+Shift+O | Defaults avoid keys browsers keep for themselves (Alt+←/→ back/forward, Alt+D/E/F). Each handler is enabled only while its control is on screen. Ctrl+S is captured on About Me only, and saves only when there are unsaved edits. The Notifications logic is shared between the menu item and the shortcut. README shortcut table updated. ## Testing - Lint, typecheck, unit tests and build pass. - Every new shortcut exercised in the browser at 1440x900 with its visible effect confirmed (reset/extend states, minutes focus, shuffle flag, animation cycling, archived/backlog/range toggles, period navigation incl. disabled forward arrow, chart range cycle, visibility toggle, back, save, Sounds panel, Notifications dialog). All 16 appear in the Ctrl+/ panel. - Not reached: the Notifications "ask permission" branch (test browser had permission denied); it reuses the menu item's existing logic. --- README.md | 11 +++++++++ src/components/boost/BoostFeed.tsx | 6 ++++- src/components/focus/AnimationPicker.tsx | 5 +++- src/components/focus/FocusTimer.tsx | 19 ++++++++++++--- src/components/focus/GoalForm.tsx | 8 ++++++ src/components/hotkeys/bindings.ts | 16 ++++++++++++ src/components/pages/AboutMePage.tsx | 9 +++++++ src/components/pages/GoalsPage.tsx | 6 ++++- src/components/pages/HabitsPage.tsx | 24 ++++++++++++++---- src/components/pages/HistoryPage.tsx | 26 +++++++++++++++++--- src/components/pages/TodosPage.tsx | 14 ++++++++--- src/components/settings/TagsPage.tsx | 10 ++++++-- src/components/shell/StartMenu.tsx | 31 ++++++++++++++++++++---- 13 files changed, 160 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index e742811..dd56d80 100644 --- a/README.md +++ b/README.md @@ -74,14 +74,25 @@ Press Ctrl+/ to open the shortcut list. Click a key to cha | | Focus summary | Alt+H | | | About Me · Tags | Alt+P · Alt+T | | | Open / close menu | Alt+M | +| | Back (Tags page) | Alt+Shift+B | | Focus timer | Start / pause / resume | Alt+S | | | Switch focus / break | Alt+B | | | End or complete session | Alt+X | | | Edit focus goal · Choose animation | Alt+G · Alt+A | | | Add tag | Alt+Shift+T | +| | Reset session · Extend finished session | Alt+R · Alt+Shift+E | +| | Edit minutes | Alt+I | +| | Auto-shuffle animations on/off · Next animation | Alt+Shift+A · Alt+Shift+C | | Lists | New item on the current page | Alt+N | | | Post to Boost · Attach media | Ctrl+Enter · Alt+U | +| | Public / private post | Alt+Shift+V | +| | Show / hide archived · Show / hide backlog | Alt+Shift+H · Alt+Shift+K | +| | Switch 7 / 30 days (Habits) | Alt+Shift+D | +| | Previous · next period | Alt+Shift+← · Alt+Shift+→ | +| | Next chart range (Focus summary) | Alt+Shift+P | | App | Edit display name | Alt+Shift+N | +| | Save page (About Me) | Ctrl+S | +| | Sounds · Notifications | Alt+Shift+S · Alt+Shift+O | | | Show or hide the shortcut list | Ctrl+/ | Shortcuts without Ctrl, Alt or Meta are ignored while you type in a field. Hover a button to see its current shortcut. On Linux, Chrome reserves Alt+1…8 for switching tabs; rebind those if they clash. diff --git a/src/components/boost/BoostFeed.tsx b/src/components/boost/BoostFeed.tsx index b949427..e46f1bb 100644 --- a/src/components/boost/BoostFeed.tsx +++ b/src/components/boost/BoostFeed.tsx @@ -2,7 +2,8 @@ import { useEffect, useRef, useState, type DragEvent, type FormEvent } from "react"; import { Globe, Handshake, Loader2, Lock, Paperclip, Send, Sparkles, Trash2, X } from "../icons"; -import { useHotkey } from "../hotkeys/HotkeysProvider"; +import { hotkeyHint } from "../focus/GoalForm"; +import { useHotkey, useHotkeyLabel } from "../hotkeys/HotkeysProvider"; import { getDisplayName } from "../lib/profile"; import { getLocalUserId, localDb, type Row } from "../lib/local-db"; import { PostContent } from "./PostContent"; @@ -106,6 +107,8 @@ export function BoostFeed() { useHotkey("list.new", () => composerRef.current?.focus()); useHotkey("boost.post", () => formRef.current?.requestSubmit()); useHotkey("boost.attach", () => fileInputRef.current?.click()); + useHotkey("boost.visibility", () => setIsPublic((current) => !current), !loading); + const visibilityCombo = useHotkeyLabel("boost.visibility"); const dragDepthRef = useRef(0); useEffect(() => { @@ -313,6 +316,7 @@ export function BoostFeed() { -
+
{ANIMATIONS.map(({ type, title }) => { const isSelected = selectedAnimation === type; return ( diff --git a/src/components/focus/FocusTimer.tsx b/src/components/focus/FocusTimer.tsx index bbae7ba..a21753f 100644 --- a/src/components/focus/FocusTimer.tsx +++ b/src/components/focus/FocusTimer.tsx @@ -192,6 +192,11 @@ export const FocusTimer = forwardRef(function localStorage.setItem(SELECTED_ANIMATION_KEY, type); }; + const selectNextAnimation = () => { + const index = ANIMATIONS.findIndex((animation) => animation.type === selectedAnimation); + selectAnimation(ANIMATIONS[(index + 1) % ANIMATIONS.length].type); + }; + const rerollAnimation = () => { if (autoShuffle) setSelectedAnimation(randomAnimationOtherThan(selectedAnimation)); }; @@ -533,9 +538,16 @@ export const FocusTimer = forwardRef(function useHotkey("timer.end", endCurrent, isActive); useHotkey("timer.switch", switchMode, isActive); useHotkey("timer.animation", () => setShowPicker((visible) => !visible), isActive && isIdle); + useHotkey("timer.shuffle", toggleAutoShuffle, isActive && isIdle); + useHotkey("timer.nextAnimation", selectNextAnimation, isActive && isIdle); + const isSessionComplete = !isIdle && !isOnBreak && focus.isCompleted; + useHotkey("timer.reset", resetTimer, isActive && !isIdle && !isDirectBreak && !isOnBreak); + useHotkey("timer.extend", () => void extendFocus(), isActive && isSessionComplete); const toggleCombo = useHotkeyLabel("timer.toggle"); const endCombo = useHotkeyLabel("timer.end"); const switchCombo = useHotkeyLabel("timer.switch"); + const resetCombo = useHotkeyLabel("timer.reset"); + const extendCombo = useHotkeyLabel("timer.extend"); const inputForPhase = isOnBreak ? breakMinutes : inputMinutes; const frameState: FrameState = { isOnBreak, @@ -631,7 +643,7 @@ export const FocusTimer = forwardRef(function />
- {focus.isCompleted && !isOnBreak ? ( + {isSessionComplete ? (
@@ -654,6 +666,7 @@ export const FocusTimer = forwardRef(function min
diff --git a/src/components/hotkeys/bindings.ts b/src/components/hotkeys/bindings.ts index 8eb5f7e..14e0177 100644 --- a/src/components/hotkeys/bindings.ts +++ b/src/components/hotkeys/bindings.ts @@ -19,6 +19,7 @@ export const HOTKEY_ACTIONS: readonly HotkeyAction[] = [ { id: "nav.aboutme", group: "Navigate", label: "About Me", defaultCombo: "Alt+P" }, { id: "nav.tags", group: "Navigate", label: "Tags", defaultCombo: "Alt+T" }, { id: "nav.menu", group: "Navigate", label: "Open / close menu", defaultCombo: "Alt+M" }, + { id: "page.back", group: "Navigate", label: "Back", defaultCombo: "Alt+Shift+B" }, { id: "timer.toggle", group: "Focus timer", label: "Start / pause / resume", defaultCombo: "Alt+S" }, { id: "timer.switch", group: "Focus timer", label: "Switch focus / break", defaultCombo: "Alt+B" }, @@ -26,12 +27,27 @@ export const HOTKEY_ACTIONS: readonly HotkeyAction[] = [ { id: "timer.goal", group: "Focus timer", label: "Edit focus goal", defaultCombo: "Alt+G" }, { id: "timer.animation", group: "Focus timer", label: "Choose animation", defaultCombo: "Alt+A" }, { id: "timer.addTag", group: "Focus timer", label: "Add tag", defaultCombo: "Alt+Shift+T" }, + { id: "timer.reset", group: "Focus timer", label: "Reset session", defaultCombo: "Alt+R" }, + { id: "timer.extend", group: "Focus timer", label: "Extend finished session", defaultCombo: "Alt+Shift+E" }, + { id: "timer.minutes", group: "Focus timer", label: "Edit minutes", defaultCombo: "Alt+I" }, + { id: "timer.shuffle", group: "Focus timer", label: "Auto-shuffle animations on/off", defaultCombo: "Alt+Shift+A" }, + { id: "timer.nextAnimation", group: "Focus timer", label: "Next animation", defaultCombo: "Alt+Shift+C" }, { id: "list.new", group: "Lists", label: "New item on current page", defaultCombo: "Alt+N" }, { id: "boost.post", group: "Lists", label: "Post to Boost", defaultCombo: "Ctrl+Enter" }, { id: "boost.attach", group: "Lists", label: "Attach media to post", defaultCombo: "Alt+U" }, + { id: "boost.visibility", group: "Lists", label: "Public / private post", defaultCombo: "Alt+Shift+V" }, + { id: "list.archived", group: "Lists", label: "Show / hide archived", defaultCombo: "Alt+Shift+H" }, + { id: "list.backlog", group: "Lists", label: "Show / hide backlog", defaultCombo: "Alt+Shift+K" }, + { id: "list.range", group: "Lists", label: "Switch 7 / 30 days", defaultCombo: "Alt+Shift+D" }, + { id: "list.prev", group: "Lists", label: "Previous period", defaultCombo: "Alt+Shift+Left" }, + { id: "list.next", group: "Lists", label: "Next period", defaultCombo: "Alt+Shift+Right" }, + { id: "history.period", group: "Lists", label: "Next chart range", defaultCombo: "Alt+Shift+P" }, { id: "app.displayName", group: "App", label: "Edit display name", defaultCombo: "Alt+Shift+N" }, + { id: "page.save", group: "App", label: "Save page", defaultCombo: "Ctrl+S" }, + { id: "app.sounds", group: "App", label: "Sounds", defaultCombo: "Alt+Shift+S" }, + { id: "app.notifications", group: "App", label: "Notifications", defaultCombo: "Alt+Shift+O" }, { id: "app.fullPage", group: "App", label: "Open full page (extension popup)", defaultCombo: "Alt+Enter" }, { id: "app.shortcuts", group: "App", label: "Show or hide this list", defaultCombo: "Ctrl+/" }, ]; diff --git a/src/components/pages/AboutMePage.tsx b/src/components/pages/AboutMePage.tsx index 47270ee..2e94101 100644 --- a/src/components/pages/AboutMePage.tsx +++ b/src/components/pages/AboutMePage.tsx @@ -1,6 +1,8 @@ "use client"; import { useEffect, useState } from "react"; +import { hotkeyHint } from "../focus/GoalForm"; +import { useHotkey, useHotkeyLabel } from "../hotkeys/HotkeysProvider"; import { Save } from "../icons"; import { localDb, type Row } from "../lib/local-db"; @@ -77,6 +79,12 @@ export function AboutMePage({ userId }: { userId: string }) { setSaving(false); }; + // Always claimed on this page so Ctrl+S never opens the browser's "Save page" dialog here. + useHotkey("page.save", () => { + if (dirty && !saving) void save(); + }); + const saveCombo = useHotkeyLabel("page.save"); + // Cmd/Ctrl+Enter saves from anywhere on the page. useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { @@ -142,6 +150,7 @@ export function AboutMePage({ userId }: { userId: string }) { @@ -356,6 +359,16 @@ export function HabitsPage({ userId }: { userId: string }) { setViewMode(mode); setDayOffset(0); }; + // The period bar is only rendered while there are active habits. + const hasPeriodBar = !loading && sortedActive.length > 0; + const previousPeriod = () => setDayOffset(dayOffset + 1); + const nextPeriod = () => dayOffset > 0 && setDayOffset(dayOffset - 1); + useHotkey("list.prev", previousPeriod, hasPeriodBar); + useHotkey("list.next", nextPeriod, hasPeriodBar); + useHotkey("list.range", () => pickView(viewMode === "7days" ? "30days" : "7days"), hasPeriodBar); + const prevCombo = useHotkeyLabel("list.prev"); + const nextCombo = useHotkeyLabel("list.next"); + const rangeCombo = useHotkeyLabel("list.range"); const viewButton = (mode: ViewMode) => "px-3 py-1 text-sm rounded-md transition-colors " + (viewMode === mode ? "bg-blue-600 text-white" : "text-gray-400 hover:text-white"); if (loading) { @@ -391,12 +404,12 @@ export function HabitsPage({ userId }: { userId: string }) { ) : (
-
{rangeLabel(dayOffset, viewMode)} -
+
@@ -406,8 +419,9 @@ export function HabitsPage({ userId }: { userId: string }) {
@@ -160,6 +163,9 @@ export function TodosPage({ userId, onFocusWithTodo }: { userId: string; onFocus const backlogTodos = useMemo(() => todos.filter((t) => !t.archived && t.backlog).sort(byPosition), [todos]); const archivedTodos = useMemo(() => todos.filter((t) => t.archived), [todos]); const mainSectionTodos = useMemo(() => [...suggestedTodos, ...mainTodos], [suggestedTodos, mainTodos]); + const hasBacklogToggle = mainSectionTodos.length > 0 || backlogTodos.length > 0; + useHotkey("list.backlog", () => setShowBacklog((current) => !current), hasBacklogToggle); + const backlogCombo = useHotkeyLabel("list.backlog"); const updateTodo = async (id: string, updates: TodoUpdates) => { const original = todos.find((t) => t.id === id); @@ -382,9 +388,9 @@ export function TodosPage({ userId, onFocusWithTodo }: { userId: string; onFocus - {(mainSectionTodos.length > 0 || backlogTodos.length > 0) && ( + {hasBacklogToggle && (
- diff --git a/src/components/settings/TagsPage.tsx b/src/components/settings/TagsPage.tsx index 354152a..396c6a0 100644 --- a/src/components/settings/TagsPage.tsx +++ b/src/components/settings/TagsPage.tsx @@ -2,7 +2,8 @@ import { useCallback, useEffect, useState } from "react"; import { Archive, ArchiveX, ArrowLeft, Pen, Plus, Save, Trash2, XCircle } from "../icons"; -import { useHotkey } from "../hotkeys/HotkeysProvider"; +import { hotkeyHint } from "../focus/GoalForm"; +import { useHotkey, useHotkeyLabel } from "../hotkeys/HotkeysProvider"; import { localDb } from "../lib/local-db"; import { createTagInDb, fetchTags, type Tag } from "../pages/history/data"; import type { SubPageProps } from "../types"; @@ -76,6 +77,10 @@ export function TagsPage({ userId, onBack }: SubPageProps) { const [newColor, setNewColor] = useState(TAG_COLORS[0]); const [showArchived, setShowArchived] = useState(false); useHotkey("list.new", () => setIsAdding(true)); + useHotkey("page.back", onBack); + useHotkey("list.archived", () => setShowArchived((current) => !current), tags.some((t) => t.archived)); + const backCombo = useHotkeyLabel("page.back"); + const archivedCombo = useHotkeyLabel("list.archived"); const loadTags = useCallback(async () => { try { @@ -170,7 +175,7 @@ export function TagsPage({ userId, onBack }: SubPageProps) {
-
@@ -286,6 +291,7 @@ export function TagsPage({ userId, onBack }: SubPageProps) {
@@ -52,6 +60,7 @@ function NotificationsItem() { } export function StartMenu({ onNavigate }: { onNavigate(tab: Tab): void }) { + const dialog = useDialog(); const [isOpen, setIsOpen] = useState(false); const [isSettingsOpen, setIsSettingsOpen] = useState(true); const [isSoundsOpen, setIsSoundsOpen] = useState(false); @@ -66,6 +75,17 @@ export function StartMenu({ onNavigate }: { onNavigate(tab: Tab): void }) { const menuCombo = useHotkeyLabel("nav.menu"); const fullPageCombo = useHotkeyLabel("app.fullPage"); useHotkey("app.fullPage", () => void openFullPage(), isPopup); + useHotkey("app.sounds", () => { + setIsSoundsOpen(true); + setIsOpen(false); + }); + const soundsCombo = useHotkeyLabel("app.sounds"); + const [notificationsSupported] = useState(() => notificationStatus() !== "unsupported"); + // Closing the menu keeps its Notifications item from showing a status the hotkey just changed. + useHotkey("app.notifications", () => { + setIsOpen(false); + void actOnNotifications(dialog); + }, notificationsSupported); useEffect(() => { if (isOpen) panelRef.current?.querySelector("button")?.focus(); @@ -134,6 +154,7 @@ export function StartMenu({ onNavigate }: { onNavigate(tab: Tab): void }) { setIsSoundsOpen(true); setIsOpen(false); }} + title={hotkeyHint("Sounds", soundsCombo)} className={SUB_ITEM} >