Skip to content

Commit 05fe852

Browse files
committed
feat: add keyboard navigation and visual selection for clipboard history
1 parent 9c042a8 commit 05fe852

6 files changed

Lines changed: 121 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,18 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## [0.0.6] - 2024-12-14
6+
7+
### Added
8+
- Keyboard navigation for clipboard history
9+
- ↑/↓ arrows to navigate items
10+
- Enter or C to copy selected item
11+
- / to focus search input
12+
- Escape to unfocus search
13+
- Visual highlight for selected item
14+
- Keyboard hints in search bar
15+
- Release script for easier publishing
16+
517
## [0.0.5] - 2024-12-14
618

719
### Added

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "clipai",
3-
"version": "0.0.5",
3+
"version": "0.0.6",
44
"description": "Modern clipboard history manager powered by AI",
55
"author": "Mikheil Berishvili",
66
"main": "dist/main/main.js",
@@ -20,7 +20,8 @@
2020
"package": "bun run build && electron-builder",
2121
"package:all": "bun run build && electron-builder --mac --win",
2222
"package:dir": "bun run build && electron-builder --dir",
23-
"rebuild": "electron-rebuild"
23+
"rebuild": "electron-rebuild",
24+
"release": "npm version patch --no-git-tag-version"
2425
},
2526
"build": {
2627
"appId": "com.clipai.app",

src/renderer/components/ClipboardItem.css

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,19 @@
3333
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
3434
}
3535

36+
/* Selected state */
37+
.history-item--selected {
38+
background: rgba(99, 102, 241, 0.15);
39+
border-color: rgba(99, 102, 241, 0.4);
40+
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.3),
41+
0 8px 32px rgba(99, 102, 241, 0.1);
42+
}
43+
44+
.history-item--selected .item-number {
45+
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
46+
color: white;
47+
}
48+
3649
@keyframes itemSlideIn {
3750
from {
3851
opacity: 0;

src/renderer/components/ClipboardItem.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ interface ClipboardItemProps {
1818
timestamp: number;
1919
};
2020
index: number;
21+
isSelected?: boolean;
22+
onSelect?: () => void;
2123
}
2224

2325
function formatTimestamp(timestamp: number): string {
@@ -35,7 +37,7 @@ function formatTimestamp(timestamp: number): string {
3537
return new Date(timestamp).toLocaleDateString();
3638
}
3739

38-
export default function HistoryItemCard({ item, index }: ClipboardItemProps) {
40+
export default function HistoryItemCard({ item, index, isSelected, onSelect }: ClipboardItemProps) {
3941
const [detected, setDetected] = useState<DetectedContent | null>(null);
4042
const [isExpanded, setIsExpanded] = useState(false);
4143
const [copied, setCopied] = useState(false);
@@ -81,7 +83,11 @@ export default function HistoryItemCard({ item, index }: ClipboardItemProps) {
8183

8284
if (item.type === "image") {
8385
return (
84-
<div className="history-item">
86+
<div
87+
className={`history-item ${isSelected ? 'history-item--selected' : ''}`}
88+
data-index={index}
89+
onClick={onSelect}
90+
>
8591
<div className="item-number">{index + 1}</div>
8692
<div className="item-content">
8793
<div className="item-header">
@@ -123,7 +129,11 @@ export default function HistoryItemCard({ item, index }: ClipboardItemProps) {
123129
: detected.formatted);
124130

125131
return (
126-
<div className="history-item">
132+
<div
133+
className={`history-item ${isSelected ? 'history-item--selected' : ''}`}
134+
data-index={index}
135+
onClick={onSelect}
136+
>
127137
<div className="item-number">{index + 1}</div>
128138
<div className="item-content">
129139
<div className="item-header">

src/renderer/pages/ClipboardHistory.tsx

Lines changed: 78 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState, useEffect } from "react";
1+
import { useState, useEffect, useCallback, useRef } from "react";
22
import type { ClipboardItem as ClipboardItemType } from "../../models/ClipboardItem";
33
import HistoryItemCard from "../components/ClipboardItem";
44
import "./ClipboardHistory.css";
@@ -11,6 +11,9 @@ export default function ClipboardHistory({}: ClipboardHistoryProps) {
1111
const [hasMore, setHasMore] = useState(true);
1212
const [searchQuery, setSearchQuery] = useState("");
1313
const [hasApiKey, setHasApiKey] = useState(false);
14+
const [selectedIndex, setSelectedIndex] = useState(0);
15+
const listRef = useRef<HTMLDivElement>(null);
16+
const searchInputRef = useRef<HTMLInputElement>(null);
1417

1518
const performSearch = async () => {
1619
console.log("Search triggered for:", searchQuery);
@@ -43,6 +46,74 @@ export default function ClipboardHistory({}: ClipboardHistoryProps) {
4346
}
4447
};
4548

49+
const copySelectedItem = useCallback(async () => {
50+
const item = history[selectedIndex];
51+
if (!item) return;
52+
53+
if (item.type === "text" && item.text) {
54+
await navigator.clipboard.writeText(item.text);
55+
} else if (item.type === "image" && item.image) {
56+
const response = await fetch(item.image);
57+
const blob = await response.blob();
58+
await navigator.clipboard.write([
59+
new (window as any).ClipboardItem({ [blob.type]: blob }),
60+
]);
61+
}
62+
}, [history, selectedIndex]);
63+
64+
// Global keyboard navigation
65+
useEffect(() => {
66+
const handleGlobalKeyDown = (e: KeyboardEvent) => {
67+
// Don't handle if typing in search input
68+
if (document.activeElement === searchInputRef.current && e.key !== "Escape" && e.key !== "ArrowDown" && e.key !== "ArrowUp") {
69+
return;
70+
}
71+
72+
switch (e.key) {
73+
case "ArrowDown":
74+
e.preventDefault();
75+
setSelectedIndex((prev) => Math.min(prev + 1, history.length - 1));
76+
break;
77+
case "ArrowUp":
78+
e.preventDefault();
79+
setSelectedIndex((prev) => Math.max(prev - 1, 0));
80+
break;
81+
case "Enter":
82+
case "c":
83+
case "C":
84+
if (document.activeElement !== searchInputRef.current) {
85+
e.preventDefault();
86+
copySelectedItem();
87+
}
88+
break;
89+
case "Escape":
90+
e.preventDefault();
91+
searchInputRef.current?.blur();
92+
break;
93+
case "/":
94+
if (document.activeElement !== searchInputRef.current) {
95+
e.preventDefault();
96+
searchInputRef.current?.focus();
97+
}
98+
break;
99+
}
100+
};
101+
102+
window.addEventListener("keydown", handleGlobalKeyDown);
103+
return () => window.removeEventListener("keydown", handleGlobalKeyDown);
104+
}, [history.length, copySelectedItem]);
105+
106+
// Scroll selected item into view
107+
useEffect(() => {
108+
const selectedElement = listRef.current?.querySelector(`[data-index="${selectedIndex}"]`);
109+
selectedElement?.scrollIntoView({ block: "nearest", behavior: "smooth" });
110+
}, [selectedIndex]);
111+
112+
// Reset selection when history changes
113+
useEffect(() => {
114+
setSelectedIndex(0);
115+
}, [history.length]);
116+
46117
useEffect(() => {
47118
window.electronAPI.getClipboardHistory().then((items) => {
48119
console.log(`Initial history loaded: ${items.length} items`);
@@ -120,11 +191,12 @@ export default function ClipboardHistory({}: ClipboardHistoryProps) {
120191
/>
121192
</svg>
122193
<input
194+
ref={searchInputRef}
123195
type="text"
124196
className="search-input"
125197
placeholder={
126198
hasApiKey
127-
? "Search with AI..."
199+
? "Search with AI... (press / to focus)"
128200
: "Configure OpenAI API key in settings..."
129201
}
130202
value={searchQuery}
@@ -156,7 +228,7 @@ export default function ClipboardHistory({}: ClipboardHistoryProps) {
156228
)}
157229
</div>
158230
{hasApiKey ? (
159-
<p className="search-hint">Press Enter to search</p>
231+
<p className="search-hint">Press Enter to search • ↑↓ Navigate • Enter/C to copy • / to search</p>
160232
) : (
161233
<p className="search-warning">
162234
OpenAI API key required. Configure in Settings to enable semantic
@@ -172,12 +244,14 @@ export default function ClipboardHistory({}: ClipboardHistoryProps) {
172244
</div>
173245
) : (
174246
<>
175-
<div className="history-list">
247+
<div className="history-list" ref={listRef}>
176248
{history.map((item, index) => (
177249
<HistoryItemCard
178250
key={item.id || index}
179251
item={item}
180252
index={index}
253+
isSelected={index === selectedIndex}
254+
onSelect={() => setSelectedIndex(index)}
181255
/>
182256
))}
183257
</div>

0 commit comments

Comments
 (0)