Skip to content

Commit 7b612c7

Browse files
committed
feat: add page element selection
1 parent c17ef95 commit 7b612c7

File tree

6 files changed

+698
-113
lines changed

6 files changed

+698
-113
lines changed

src/components/chat/shortcutHandler.ts

Lines changed: 89 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -19,39 +19,68 @@ export class ShortcutHandler {
1919
private setupEventListeners(): void {
2020
this.input.addEventListener('input', () => this.handleInput());
2121
this.input.addEventListener('keydown', (e) => this.handleKeydown(e));
22+
23+
// Add support for Tab key to accept first suggestion
24+
this.input.addEventListener('keydown', (e) => {
25+
if (e.key === 'Tab' && this.matches.length > 0 && this.dropdown) {
26+
e.preventDefault();
27+
this.applyShortcut(this.matches[this.selectedIndex].command);
28+
}
29+
});
30+
2231
document.addEventListener('click', (e) => {
23-
if (e.target !== this.input && this.dropdown) {
32+
if (e.target !== this.input && this.dropdown && !this.dropdown.contains(e.target as Node)) {
2433
this.hideDropdown();
2534
}
2635
});
2736
}
2837

2938
private async handleInput(): Promise<void> {
3039
const text = this.input.value;
31-
const lastWord = text.split(' ').pop() || '';
32-
33-
if (lastWord.startsWith('/')) {
34-
// Get both default shortcuts and custom prompts
35-
const [settings, { customPrompts = {} }] = await Promise.all([
36-
getSettings(),
37-
new Promise<{ customPrompts?: Record<string, string> }>(resolve => {
38-
chrome.storage.sync.get(['customPrompts'], resolve);
39-
})
40-
]);
41-
42-
// Combine default shortcuts and custom prompts
43-
const allShortcuts = {
44-
...settings.shortcuts,
45-
...customPrompts
46-
};
47-
48-
this.matches = Object.entries(allShortcuts)
49-
.filter(([command]) => command.startsWith(lastWord))
50-
.map(([command, description]) => ({ command, description }));
51-
52-
if (this.matches.length > 0) {
53-
this.showDropdown();
54-
} else {
40+
const cursorPos = this.input.selectionStart;
41+
42+
// Find the word at the cursor position
43+
const beforeCursor = text.substring(0, cursorPos);
44+
const afterCursor = text.substring(cursorPos);
45+
46+
// Find the start of the current word
47+
const lastSpaceIndex = beforeCursor.lastIndexOf(' ');
48+
const currentWord = beforeCursor.substring(lastSpaceIndex + 1);
49+
50+
if (currentWord.startsWith('/')) {
51+
try {
52+
// Get both default shortcuts and custom prompts
53+
const [settings, { customPrompts = {} }] = await Promise.all([
54+
getSettings(),
55+
new Promise<{ customPrompts?: Record<string, string> }>(resolve => {
56+
chrome.storage.sync.get(['customPrompts'], resolve);
57+
})
58+
]);
59+
60+
// Combine default shortcuts and custom prompts
61+
const allShortcuts = {
62+
...settings.shortcuts,
63+
...customPrompts
64+
};
65+
66+
// If just '/', show all shortcuts, otherwise filter by match
67+
this.matches = currentWord.length === 1
68+
? Object.entries(allShortcuts)
69+
.map(([command, description]) => ({ command, description }))
70+
.sort((a, b) => a.command.localeCompare(b.command))
71+
: Object.entries(allShortcuts)
72+
.filter(([command]) => command.toLowerCase().startsWith(currentWord.toLowerCase()))
73+
.map(([command, description]) => ({ command, description }))
74+
.sort((a, b) => a.command.localeCompare(b.command));
75+
76+
if (this.matches.length > 0) {
77+
this.selectedIndex = 0; // Reset selection to first item
78+
this.showDropdown();
79+
} else {
80+
this.hideDropdown();
81+
}
82+
} catch (error) {
83+
console.error('Error loading shortcuts:', error);
5584
this.hideDropdown();
5685
}
5786
} else {
@@ -92,7 +121,13 @@ export class ShortcutHandler {
92121
if (!this.dropdown) {
93122
this.dropdown = document.createElement('div');
94123
this.dropdown.className = 'shortcut-autocomplete';
95-
this.input.parentElement?.appendChild(this.dropdown);
124+
// Find the input section to append the dropdown there
125+
const inputSection = this.input.closest('.ai-input-section');
126+
if (inputSection) {
127+
inputSection.appendChild(this.dropdown);
128+
} else {
129+
this.input.parentElement?.appendChild(this.dropdown);
130+
}
96131
}
97132

98133
this.dropdown.innerHTML = this.matches
@@ -132,9 +167,35 @@ export class ShortcutHandler {
132167
private applyShortcut(command: string): void {
133168
// Replace the last word with the full command
134169
const words = this.input.value.split(' ');
135-
words[words.length - 1] = command;
136-
this.input.value = words.join(' ');
170+
const lastWordIndex = words.length - 1;
171+
172+
// Find the position of the current word
173+
const beforeCursor = this.input.value.substring(0, this.input.selectionStart);
174+
const afterCursor = this.input.value.substring(this.input.selectionStart);
175+
176+
// Find the start of the current word
177+
const lastSpaceIndex = beforeCursor.lastIndexOf(' ');
178+
const currentWord = beforeCursor.substring(lastSpaceIndex + 1);
179+
180+
if (currentWord.startsWith('/')) {
181+
// Replace just the command part
182+
const newValue = beforeCursor.substring(0, lastSpaceIndex + 1) + command + ' ' + afterCursor;
183+
this.input.value = newValue;
184+
185+
// Set cursor position after the command
186+
const newCursorPos = lastSpaceIndex + 1 + command.length + 1;
187+
this.input.setSelectionRange(newCursorPos, newCursorPos);
188+
} else {
189+
// Fallback to word replacement
190+
words[lastWordIndex] = command;
191+
this.input.value = words.join(' ') + ' ';
192+
193+
// Move cursor to end
194+
this.input.setSelectionRange(this.input.value.length, this.input.value.length);
195+
}
196+
137197
this.hideDropdown();
198+
this.input.focus();
138199

139200
// Handle special commands
140201
switch (command) {

src/components/context/__tests__/contextModes.test.ts

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,13 @@ describe('Context Modes', () => {
3030
// Setup DOM elements
3131
container = document.createElement('div');
3232
container.innerHTML = `
33-
<div class="slider-container">
34-
<div class="slider-option" data-mode="page">Full Page</div>
35-
<div class="slider-option" data-mode="selection">Selection</div>
36-
<div class="slider-option" data-mode="screenshot">Screenshot</div>
37-
<div class="slider-highlight"></div>
33+
<div class="ai-slider-container">
34+
<div class="ai-slider-option" data-mode="page">Full Page</div>
35+
<div class="ai-slider-option" data-mode="selection">Selection</div>
36+
<div class="ai-slider-option" data-mode="element">Element</div>
37+
<div class="ai-slider-option" data-mode="screenshot">Screenshot</div>
38+
<div class="ai-slider-option" data-mode="youtube">YouTube</div>
39+
<div class="ai-slider-highlight"></div>
3840
</div>
3941
<button id="ai-screenshot-btn">Take Screenshot</button>
4042
<div id="ai-drop-zone">Drop zone</div>
@@ -46,8 +48,8 @@ describe('Context Modes', () => {
4648
// Get elements
4749
contentPreview = document.getElementById('ai-content-preview') as HTMLDivElement;
4850
screenshotBtn = document.getElementById('ai-screenshot-btn') as HTMLButtonElement;
49-
options = document.querySelectorAll('.slider-option');
50-
highlight = document.querySelector('.slider-highlight') as HTMLElement;
51+
options = document.querySelectorAll('.ai-slider-option');
52+
highlight = document.querySelector('.ai-slider-highlight') as HTMLElement;
5153

5254
// Add toggle button and sidebar
5355
toggleButton = document.createElement('button');
@@ -89,9 +91,14 @@ describe('Context Modes', () => {
8991
expect(highlight.style.transform).toBe('translateX(100%)');
9092
expect(screenshotBtn.classList.contains('hidden')).toBe(true);
9193

92-
// Click screenshot mode
94+
// Click element mode
9395
options[2].dispatchEvent(new Event('click'));
9496
expect(highlight.style.transform).toBe('translateX(200%)');
97+
expect(screenshotBtn.classList.contains('hidden')).toBe(true);
98+
99+
// Click screenshot mode
100+
options[3].dispatchEvent(new Event('click'));
101+
expect(highlight.style.transform).toBe('translateX(300%)');
95102
expect(screenshotBtn.classList.contains('hidden')).toBe(false);
96103
});
97104

@@ -100,7 +107,7 @@ describe('Context Modes', () => {
100107
jest.advanceTimersByTime(100);
101108

102109
// Switch to screenshot mode first
103-
options[2].dispatchEvent(new Event('click'));
110+
options[3].dispatchEvent(new Event('click'));
104111

105112
// Take screenshot
106113
const screenshotPromise = new Promise<void>((resolve) => {
@@ -140,8 +147,13 @@ describe('Context Modes', () => {
140147
expect(getPageContent()).toBe('Selected text');
141148
});
142149

150+
it('should return element text in element mode', () => {
151+
options[2].dispatchEvent(new Event('click')); // Switch to element mode
152+
expect(getPageContent()).toBe('No element selected');
153+
});
154+
143155
it('should return screenshot data in screenshot mode', async () => {
144-
options[2].dispatchEvent(new Event('click')); // Switch to screenshot mode
156+
options[3].dispatchEvent(new Event('click')); // Switch to screenshot mode
145157

146158
// Take screenshot
147159
const screenshotPromise = new Promise<void>((resolve) => {

0 commit comments

Comments
 (0)