mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
Add keyboard shortcut highlighter to terminal (#114)
* Add keyboard shortcut highlighter to terminal - Create keyboard-shortcut-highlighter.ts with comprehensive pattern matching - Integrate highlighter into terminal component alongside URL highlighting - Support clickable shortcuts like "Ctrl+R", "Ctrl+A", "esc to interrupt" - Style shortcuts with greyish color and dotted underlines (not blue links) - Send actual key sequences when shortcuts are clicked - Handle overlapping matches and avoid double-processing 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Fix keyboard shortcut input handling - add missing event listener The keyboard shortcut highlighter was dispatching 'terminal-input' events but the session-view component wasn't listening for them. Added: - @terminal-input event listener on vibe-terminal component - handleTerminalInput method that forwards to inputManager.sendInputText() Now clicked shortcuts like 'Ctrl+R' properly send input to the terminal. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Add Claude Code interactive prompt support to keyboard shortcut highlighter Make numbered options in Claude Code prompts clickable: - Pattern: '❯ 1. Yes' or ' 2. Yes, and don't ask again' - Clicking sends the number (1, 2, 3, etc.) to terminal - Handles both selected (❯) and unselected options - Uses multiline regex matching for line-start patterns Now users can click numbered options instead of typing numbers. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Fix Claude Code prompt patterns to handle indented options Previous patterns required line-start anchors which failed for indented options within bordered terminal output. Updated patterns: - Remove restrictive line-start anchors (^) - Add whitespace-flexible patterns for indented options - Support both cursor-selected (❯) and plain numbered options - Handle 'Yes, proceed' and 'No, exit' style options Now correctly matches: '❯ 1. Yes, proceed' and ' 2. No, exit' patterns. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Simplify Claude Code prompt patterns to be fully generic Replace keyword-specific patterns with generic numbered option matching: - ❯ 1. (cursor-selected options) - 1. xxxxx (any numbered option with text) Now matches any numbered list item regardless of content: '1. Yes, proceed', '2. No, exit', '3. Maybe later', etc. Much cleaner and more flexible than keyword-based matching. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Fix Claude Code prompt patterns to highlight full option lines Changed from matching only first word to matching entire lines: - /❯\s*(\d+)\.\s+.*/ - highlights complete cursor-selected option - /(\d+)\.\s+.*/ - highlights complete numbered option Now '1. Yes, proceed' and '2. No, exit' are fully underlined instead of just '1. Yes' and '2. No'. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
660880b396
commit
bdfd46a915
3 changed files with 392 additions and 0 deletions
|
|
@ -729,6 +729,13 @@ export class SessionView extends LitElement {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handleTerminalInput(e: CustomEvent) {
|
||||||
|
const { text } = e.detail;
|
||||||
|
if (this.inputManager && text) {
|
||||||
|
await this.inputManager.sendInputText(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private updateTerminalTransform(): void {
|
private updateTerminalTransform(): void {
|
||||||
// Calculate height reduction for keyboard and quick keys
|
// Calculate height reduction for keyboard and quick keys
|
||||||
let heightReduction = 0;
|
let heightReduction = 0;
|
||||||
|
|
@ -895,6 +902,7 @@ export class SessionView extends LitElement {
|
||||||
.hideScrollButton=${this.showQuickKeys}
|
.hideScrollButton=${this.showQuickKeys}
|
||||||
class="w-full h-full p-0 m-0"
|
class="w-full h-full p-0 m-0"
|
||||||
@click=${this.handleTerminalClick}
|
@click=${this.handleTerminalClick}
|
||||||
|
@terminal-input=${this.handleTerminalInput}
|
||||||
></vibe-terminal>
|
></vibe-terminal>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
import { type IBufferCell, type IBufferLine, Terminal as XtermTerminal } from '@xterm/headless';
|
import { type IBufferCell, type IBufferLine, Terminal as XtermTerminal } from '@xterm/headless';
|
||||||
import { html, LitElement, type PropertyValues } from 'lit';
|
import { html, LitElement, type PropertyValues } from 'lit';
|
||||||
import { customElement, property, state } from 'lit/decorators.js';
|
import { customElement, property, state } from 'lit/decorators.js';
|
||||||
|
import { processKeyboardShortcuts } from '../utils/keyboard-shortcut-highlighter.js';
|
||||||
import { createLogger } from '../utils/logger.js';
|
import { createLogger } from '../utils/logger.js';
|
||||||
import { UrlHighlighter } from '../utils/url-highlighter';
|
import { UrlHighlighter } from '../utils/url-highlighter';
|
||||||
|
|
||||||
|
|
@ -731,6 +732,9 @@ export class Terminal extends LitElement {
|
||||||
// Process links after rendering
|
// Process links after rendering
|
||||||
UrlHighlighter.processLinks(this.container);
|
UrlHighlighter.processLinks(this.container);
|
||||||
|
|
||||||
|
// Process keyboard shortcuts after rendering
|
||||||
|
processKeyboardShortcuts(this.container, this.handleShortcutClick);
|
||||||
|
|
||||||
// Track render performance in debug mode
|
// Track render performance in debug mode
|
||||||
if (this.debugMode) {
|
if (this.debugMode) {
|
||||||
const endTime = performance.now();
|
const endTime = performance.now();
|
||||||
|
|
@ -1239,6 +1243,16 @@ export class Terminal extends LitElement {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private handleShortcutClick = (keySequence: string) => {
|
||||||
|
// Dispatch a custom event with the keyboard shortcut
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent('terminal-input', {
|
||||||
|
detail: { text: keySequence },
|
||||||
|
bubbles: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return html`
|
return html`
|
||||||
<style>
|
<style>
|
||||||
|
|
|
||||||
370
web/src/client/utils/keyboard-shortcut-highlighter.ts
Normal file
370
web/src/client/utils/keyboard-shortcut-highlighter.ts
Normal file
|
|
@ -0,0 +1,370 @@
|
||||||
|
/**
|
||||||
|
* Keyboard Shortcut Highlighter utility for DOM terminal
|
||||||
|
*
|
||||||
|
* Handles detection and highlighting of keyboard shortcuts in terminal content,
|
||||||
|
* making them clickable to send the actual key sequences.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
const TERMINAL_SHORTCUT_CLASS = 'terminal-shortcut';
|
||||||
|
|
||||||
|
// Keyboard shortcut patterns (case insensitive)
|
||||||
|
const SHORTCUT_PATTERNS = [
|
||||||
|
// Ctrl combinations
|
||||||
|
{
|
||||||
|
pattern: /\bctrl\+([a-z])\b/gi,
|
||||||
|
keySequence: (match: RegExpMatchArray) => `ctrl_${match[1].toLowerCase()}`,
|
||||||
|
},
|
||||||
|
{ pattern: /\bctrl\+([0-9])\b/gi, keySequence: (match: RegExpMatchArray) => `ctrl_${match[1]}` },
|
||||||
|
{
|
||||||
|
pattern: /\bctrl\+f([1-9]|1[0-2])\b/gi,
|
||||||
|
keySequence: (match: RegExpMatchArray) => `ctrl_f${match[1]}`,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Common shortcuts
|
||||||
|
{
|
||||||
|
pattern: /\bctrl\+shift\+([a-z])\b/gi,
|
||||||
|
keySequence: (match: RegExpMatchArray) => `ctrl_shift_${match[1].toLowerCase()}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: /\balt\+([a-z])\b/gi,
|
||||||
|
keySequence: (match: RegExpMatchArray) => `alt_${match[1].toLowerCase()}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: /\bcmd\+([a-z])\b/gi,
|
||||||
|
keySequence: (match: RegExpMatchArray) => `cmd_${match[1].toLowerCase()}`,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Function keys
|
||||||
|
{ pattern: /\bf([1-9]|1[0-2])\b/gi, keySequence: (match: RegExpMatchArray) => `f${match[1]}` },
|
||||||
|
|
||||||
|
// Special keys
|
||||||
|
{ pattern: /\besc\b/gi, keySequence: () => 'escape' },
|
||||||
|
{ pattern: /\bescape\b/gi, keySequence: () => 'escape' },
|
||||||
|
{ pattern: /\btab\b/gi, keySequence: () => 'tab' },
|
||||||
|
{ pattern: /\bshift\+tab\b/gi, keySequence: () => 'shift_tab' },
|
||||||
|
{ pattern: /\benter\b/gi, keySequence: () => 'enter' },
|
||||||
|
{ pattern: /\breturn\b/gi, keySequence: () => 'enter' },
|
||||||
|
{ pattern: /\bbackspace\b/gi, keySequence: () => 'backspace' },
|
||||||
|
{ pattern: /\bdelete\b/gi, keySequence: () => 'delete' },
|
||||||
|
{ pattern: /\bspace\b/gi, keySequence: () => ' ' },
|
||||||
|
|
||||||
|
// Arrow keys
|
||||||
|
{
|
||||||
|
pattern: /\barrow\s+(up|down|left|right)\b/gi,
|
||||||
|
keySequence: (match: RegExpMatchArray) => `arrow_${match[1].toLowerCase()}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: /\b(up|down|left|right)\s+arrow\b/gi,
|
||||||
|
keySequence: (match: RegExpMatchArray) => `arrow_${match[1].toLowerCase()}`,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Page keys
|
||||||
|
{
|
||||||
|
pattern: /\bpage\s+(up|down)\b/gi,
|
||||||
|
keySequence: (match: RegExpMatchArray) => `page_${match[1].toLowerCase()}`,
|
||||||
|
},
|
||||||
|
{ pattern: /\b(home|end)\b/gi, keySequence: (match: RegExpMatchArray) => match[1].toLowerCase() },
|
||||||
|
|
||||||
|
// Common phrases with shortcuts
|
||||||
|
{ pattern: /\besc\s+to\s+(interrupt|quit|exit|cancel)\b/gi, keySequence: () => 'escape' },
|
||||||
|
{ pattern: /\bpress\s+esc\b/gi, keySequence: () => 'escape' },
|
||||||
|
{ pattern: /\bpress\s+enter\b/gi, keySequence: () => 'enter' },
|
||||||
|
{ pattern: /\bpress\s+tab\b/gi, keySequence: () => 'tab' },
|
||||||
|
{
|
||||||
|
pattern: /\bpress\s+ctrl\+([a-z])\b/gi,
|
||||||
|
keySequence: (match: RegExpMatchArray) => `ctrl_${match[1].toLowerCase()}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: /\bctrl\+([a-z])\s+to\s+\w+/gi,
|
||||||
|
keySequence: (match: RegExpMatchArray) => `ctrl_${match[1].toLowerCase()}`,
|
||||||
|
},
|
||||||
|
|
||||||
|
// q to quit pattern
|
||||||
|
{ pattern: /\bq\s+to\s+(quit|exit)\b/gi, keySequence: () => 'q' },
|
||||||
|
{ pattern: /\bpress\s+q\b/gi, keySequence: () => 'q' },
|
||||||
|
|
||||||
|
// Claude Code interactive prompts - generic numbered options
|
||||||
|
{ pattern: /❯\s*(\d+)\.\s+.*/g, keySequence: (match: RegExpMatchArray) => match[1] },
|
||||||
|
{ pattern: /(\d+)\.\s+.*/g, keySequence: (match: RegExpMatchArray) => match[1] },
|
||||||
|
];
|
||||||
|
|
||||||
|
type ProcessedRange = {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ShortcutMatch {
|
||||||
|
text: string;
|
||||||
|
keySequence: string;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main entry point - process all keyboard shortcuts in a container
|
||||||
|
*/
|
||||||
|
export function processKeyboardShortcuts(
|
||||||
|
container: HTMLElement,
|
||||||
|
onShortcutClick: (keySequence: string) => void
|
||||||
|
): void {
|
||||||
|
const processor = new ShortcutProcessor(container, onShortcutClick);
|
||||||
|
processor.process();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ShortcutProcessor class encapsulates the shortcut detection and highlighting logic
|
||||||
|
*/
|
||||||
|
class ShortcutProcessor {
|
||||||
|
private container: HTMLElement;
|
||||||
|
private lines: NodeListOf<Element>;
|
||||||
|
private processedRanges: Map<number, ProcessedRange[]> = new Map();
|
||||||
|
private onShortcutClick: (keySequence: string) => void;
|
||||||
|
|
||||||
|
constructor(container: HTMLElement, onShortcutClick: (keySequence: string) => void) {
|
||||||
|
this.container = container;
|
||||||
|
this.lines = container.querySelectorAll('.terminal-line');
|
||||||
|
this.onShortcutClick = onShortcutClick;
|
||||||
|
}
|
||||||
|
|
||||||
|
process(): void {
|
||||||
|
if (this.lines.length === 0) return;
|
||||||
|
|
||||||
|
// Process each line
|
||||||
|
for (let i = 0; i < this.lines.length; i++) {
|
||||||
|
this.processLine(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private processLine(lineIndex: number): void {
|
||||||
|
const lineText = this.getLineText(lineIndex);
|
||||||
|
if (!lineText) return;
|
||||||
|
|
||||||
|
// Find all shortcuts in this line
|
||||||
|
const shortcuts = this.findShortcutsInLine(lineText);
|
||||||
|
|
||||||
|
// Create clickable shortcuts for each match
|
||||||
|
for (const shortcut of shortcuts) {
|
||||||
|
// Check if already processed
|
||||||
|
if (!this.isRangeProcessed(lineIndex, shortcut.start, shortcut.end)) {
|
||||||
|
this.createShortcutLink(shortcut, lineIndex);
|
||||||
|
this.markRangeAsProcessed(lineIndex, shortcut.start, shortcut.end);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private findShortcutsInLine(lineText: string): ShortcutMatch[] {
|
||||||
|
const shortcuts: ShortcutMatch[] = [];
|
||||||
|
|
||||||
|
for (const pattern of SHORTCUT_PATTERNS) {
|
||||||
|
// Reset the regex
|
||||||
|
pattern.pattern.lastIndex = 0;
|
||||||
|
|
||||||
|
let match = pattern.pattern.exec(lineText);
|
||||||
|
while (match !== null) {
|
||||||
|
const text = match[0];
|
||||||
|
const keySequence = pattern.keySequence(match);
|
||||||
|
const start = match.index;
|
||||||
|
const end = match.index + text.length;
|
||||||
|
|
||||||
|
shortcuts.push({
|
||||||
|
text,
|
||||||
|
keySequence,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
});
|
||||||
|
|
||||||
|
match = pattern.pattern.exec(lineText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by start position to handle overlaps
|
||||||
|
shortcuts.sort((a, b) => a.start - b.start);
|
||||||
|
|
||||||
|
// Remove overlapping matches (keep the first one)
|
||||||
|
const nonOverlapping: ShortcutMatch[] = [];
|
||||||
|
for (const shortcut of shortcuts) {
|
||||||
|
const hasOverlap = nonOverlapping.some(
|
||||||
|
(existing) => shortcut.start < existing.end && shortcut.end > existing.start
|
||||||
|
);
|
||||||
|
if (!hasOverlap) {
|
||||||
|
nonOverlapping.push(shortcut);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nonOverlapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createShortcutLink(shortcut: ShortcutMatch, lineIndex: number): void {
|
||||||
|
const line = this.lines[lineIndex];
|
||||||
|
const highlighter = new ShortcutHighlighter(line, shortcut, this.onShortcutClick);
|
||||||
|
highlighter.createLink();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getLineText(lineIndex: number): string {
|
||||||
|
if (lineIndex < 0 || lineIndex >= this.lines.length) return '';
|
||||||
|
return this.lines[lineIndex].textContent || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private isRangeProcessed(lineIndex: number, start: number, end: number): boolean {
|
||||||
|
const ranges = this.processedRanges.get(lineIndex);
|
||||||
|
if (!ranges) return false;
|
||||||
|
|
||||||
|
return ranges.some((range) => start < range.end && end > range.start);
|
||||||
|
}
|
||||||
|
|
||||||
|
private markRangeAsProcessed(lineIndex: number, start: number, end: number): void {
|
||||||
|
if (!this.processedRanges.has(lineIndex)) {
|
||||||
|
this.processedRanges.set(lineIndex, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ranges = this.processedRanges.get(lineIndex);
|
||||||
|
if (ranges) {
|
||||||
|
ranges.push({ start, end });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ShortcutHighlighter handles the DOM manipulation to create clickable shortcuts
|
||||||
|
*/
|
||||||
|
class ShortcutHighlighter {
|
||||||
|
private lineElement: Element;
|
||||||
|
private shortcut: ShortcutMatch;
|
||||||
|
private onShortcutClick: (keySequence: string) => void;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
lineElement: Element,
|
||||||
|
shortcut: ShortcutMatch,
|
||||||
|
onShortcutClick: (keySequence: string) => void
|
||||||
|
) {
|
||||||
|
this.lineElement = lineElement;
|
||||||
|
this.shortcut = shortcut;
|
||||||
|
this.onShortcutClick = onShortcutClick;
|
||||||
|
}
|
||||||
|
|
||||||
|
createLink(): void {
|
||||||
|
this.wrapTextInLink(this.lineElement, this.shortcut.start, this.shortcut.end);
|
||||||
|
}
|
||||||
|
|
||||||
|
private wrapTextInLink(lineElement: Element, startCol: number, endCol: number): void {
|
||||||
|
// First pass: collect all text nodes and their positions
|
||||||
|
const walker = document.createTreeWalker(lineElement, NodeFilter.SHOW_TEXT, null);
|
||||||
|
const textNodeData: Array<{ node: Text; start: number; end: number }> = [];
|
||||||
|
let currentPos = 0;
|
||||||
|
let node = walker.nextNode();
|
||||||
|
|
||||||
|
while (node) {
|
||||||
|
const textNode = node as Text;
|
||||||
|
const nodeText = textNode.textContent || '';
|
||||||
|
const nodeStart = currentPos;
|
||||||
|
const nodeEnd = currentPos + nodeText.length;
|
||||||
|
|
||||||
|
// Only collect nodes that overlap with our range
|
||||||
|
if (nodeEnd > startCol && nodeStart < endCol) {
|
||||||
|
textNodeData.push({ node: textNode, start: nodeStart, end: nodeEnd });
|
||||||
|
}
|
||||||
|
|
||||||
|
currentPos = nodeEnd;
|
||||||
|
node = walker.nextNode();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: process all relevant text nodes in reverse order
|
||||||
|
// (to avoid invalidating positions when modifying the DOM)
|
||||||
|
for (let i = textNodeData.length - 1; i >= 0; i--) {
|
||||||
|
const { node: textNode, start: nodeStart } = textNodeData[i];
|
||||||
|
const nodeText = textNode.textContent || '';
|
||||||
|
|
||||||
|
const linkStart = Math.max(0, startCol - nodeStart);
|
||||||
|
const linkEnd = Math.min(nodeText.length, endCol - nodeStart);
|
||||||
|
|
||||||
|
if (linkStart < linkEnd) {
|
||||||
|
this.wrapTextNode(textNode, linkStart, linkEnd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private wrapTextNode(textNode: Text, start: number, end: number): void {
|
||||||
|
const parent = textNode.parentNode;
|
||||||
|
if (!parent) return;
|
||||||
|
|
||||||
|
// Don't wrap if already inside a link or shortcut
|
||||||
|
if (this.isInsideClickable(parent as Element)) return;
|
||||||
|
|
||||||
|
const nodeText = textNode.textContent || '';
|
||||||
|
const beforeText = nodeText.substring(0, start);
|
||||||
|
const linkText = nodeText.substring(start, end);
|
||||||
|
const afterText = nodeText.substring(end);
|
||||||
|
|
||||||
|
// Create the shortcut element
|
||||||
|
const shortcutElement = this.createShortcutElement(linkText);
|
||||||
|
|
||||||
|
// Replace the text node
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
|
||||||
|
if (beforeText) {
|
||||||
|
fragment.appendChild(document.createTextNode(beforeText));
|
||||||
|
}
|
||||||
|
|
||||||
|
fragment.appendChild(shortcutElement);
|
||||||
|
|
||||||
|
if (afterText) {
|
||||||
|
fragment.appendChild(document.createTextNode(afterText));
|
||||||
|
}
|
||||||
|
|
||||||
|
parent.replaceChild(fragment, textNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private createShortcutElement(text: string): HTMLSpanElement {
|
||||||
|
const shortcut = document.createElement('span');
|
||||||
|
shortcut.className = TERMINAL_SHORTCUT_CLASS;
|
||||||
|
shortcut.style.color = '#9ca3af'; // Gray-400
|
||||||
|
shortcut.style.textDecoration = 'underline';
|
||||||
|
shortcut.style.textDecorationStyle = 'dotted';
|
||||||
|
shortcut.style.cursor = 'pointer';
|
||||||
|
shortcut.style.fontWeight = '500';
|
||||||
|
shortcut.textContent = text;
|
||||||
|
|
||||||
|
// Add click handler
|
||||||
|
shortcut.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
this.onShortcutClick(this.shortcut.keySequence);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add hover effects
|
||||||
|
shortcut.addEventListener('mouseenter', () => {
|
||||||
|
shortcut.style.backgroundColor = 'rgba(156, 163, 175, 0.2)';
|
||||||
|
shortcut.style.color = '#d1d5db'; // Gray-300
|
||||||
|
});
|
||||||
|
|
||||||
|
shortcut.addEventListener('mouseleave', () => {
|
||||||
|
shortcut.style.backgroundColor = '';
|
||||||
|
shortcut.style.color = '#9ca3af'; // Gray-400
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add title for accessibility
|
||||||
|
shortcut.title = `Click to send: ${this.shortcut.keySequence}`;
|
||||||
|
|
||||||
|
return shortcut;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isInsideClickable(element: Element): boolean {
|
||||||
|
let current: Element | null = element;
|
||||||
|
while (current && current !== document.body) {
|
||||||
|
if (
|
||||||
|
(current.tagName === 'A' && current.classList.contains('terminal-link')) ||
|
||||||
|
(current.tagName === 'SPAN' && current.classList.contains(TERMINAL_SHORTCUT_CLASS))
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
current = current.parentElement;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export as default for backwards compatibility
|
||||||
|
export const KeyboardShortcutHighlighter = {
|
||||||
|
processKeyboardShortcuts,
|
||||||
|
};
|
||||||
Loading…
Reference in a new issue