|
1 | 1 | import { api } from '../api.js'; |
| 2 | +import { DEFAULT_SORT_IGNORE_WORDS } from '../constants.js'; |
2 | 3 |
|
3 | 4 | // Default column widths in pixels (all columns have explicit widths for grid layout) |
4 | 5 | const DEFAULT_COLUMN_WIDTHS = { |
@@ -84,6 +85,10 @@ export function createLibraryBrowser(Alpine) { |
84 | 85 | columnDragStartX: 0, |
85 | 86 | wasColumnDragging: false, |
86 | 87 |
|
| 88 | + // Type-to-jump state |
| 89 | + _typeBuffer: '', // Accumulated typed characters |
| 90 | + _typeDebounceTimer: null, // Timeout ID for clearing buffer |
| 91 | + |
87 | 92 | containerWidth: 0, |
88 | 93 | resizeObserver: null, |
89 | 94 |
|
@@ -413,6 +418,9 @@ export function createLibraryBrowser(Alpine) { |
413 | 418 | } |
414 | 419 | }); |
415 | 420 |
|
| 421 | + // Type-to-jump: listen for printable characters to jump to matching artist |
| 422 | + document.addEventListener('keydown', (e) => this.handleTypeToJump(e)); |
| 423 | + |
416 | 424 | document.addEventListener('mouseup', () => { |
417 | 425 | if (this.resizingColumn) { |
418 | 426 | this.finishColumnResize(); |
@@ -1564,6 +1572,104 @@ export function createLibraryBrowser(Alpine) { |
1564 | 1572 | ); |
1565 | 1573 | }, |
1566 | 1574 |
|
| 1575 | + /** |
| 1576 | + * Handle type-to-jump navigation - jump to artist matching typed characters |
| 1577 | + * @param {KeyboardEvent} event |
| 1578 | + */ |
| 1579 | + handleTypeToJump(event) { |
| 1580 | + // Only in library view |
| 1581 | + if (this.$store.ui.view !== 'library') return; |
| 1582 | + |
| 1583 | + // Ignore if typing in input field |
| 1584 | + if (this.isTypingInInput(event)) return; |
| 1585 | + |
| 1586 | + // Ignore modifier-only keys and non-printable characters |
| 1587 | + if (event.metaKey || event.ctrlKey || event.altKey) return; |
| 1588 | + if (event.key.length !== 1) return; // Only single printable chars |
| 1589 | + |
| 1590 | + // Append to buffer |
| 1591 | + this._typeBuffer += event.key; |
| 1592 | + |
| 1593 | + // Find and scroll to matching artist |
| 1594 | + this.jumpToMatchingArtist(this._typeBuffer); |
| 1595 | + |
| 1596 | + // Reset debounce timer |
| 1597 | + this.resetTypeDebounce(); |
| 1598 | + }, |
| 1599 | + |
| 1600 | + /** |
| 1601 | + * Strip leading ignore word prefix from a string (respects sortIgnoreWords setting) |
| 1602 | + * @param {string} value - String to process (lowercase) |
| 1603 | + * @returns {string} String with prefix removed if ignore words enabled |
| 1604 | + */ |
| 1605 | + stripIgnoredPrefix(value) { |
| 1606 | + const uiStore = this.$store.ui; |
| 1607 | + if (!uiStore.sortIgnoreWords) { |
| 1608 | + return value; |
| 1609 | + } |
| 1610 | + |
| 1611 | + // Fall back to default list when user clears the input |
| 1612 | + const wordsList = uiStore.sortIgnoreWordsList?.trim() || DEFAULT_SORT_IGNORE_WORDS; |
| 1613 | + |
| 1614 | + const ignoreWords = wordsList |
| 1615 | + .split(',') |
| 1616 | + .map((w) => w.trim().toLowerCase()) |
| 1617 | + .filter(Boolean); |
| 1618 | + |
| 1619 | + for (const word of ignoreWords) { |
| 1620 | + const prefix = word + ' '; |
| 1621 | + if (value.startsWith(prefix)) { |
| 1622 | + return value.slice(prefix.length); |
| 1623 | + } |
| 1624 | + } |
| 1625 | + return value; |
| 1626 | + }, |
| 1627 | + |
| 1628 | + /** |
| 1629 | + * Find and scroll to first track with artist matching the query at a word boundary |
| 1630 | + * @param {string} query - The search query (typed characters) |
| 1631 | + */ |
| 1632 | + jumpToMatchingArtist(query) { |
| 1633 | + const normalizedQuery = query.toLowerCase(); |
| 1634 | + |
| 1635 | + // Find first track with artist matching at word boundary |
| 1636 | + const matchingTrack = this.library.filteredTracks.find((track) => { |
| 1637 | + if (!track.artist) return false; |
| 1638 | + const artist = track.artist.toLowerCase(); |
| 1639 | + |
| 1640 | + // Check if query matches at start of artist name (with ignore words stripped) |
| 1641 | + const strippedArtist = this.stripIgnoredPrefix(artist); |
| 1642 | + if (strippedArtist.startsWith(normalizedQuery)) return true; |
| 1643 | + |
| 1644 | + // Also check full artist name (for cases where user types "the") |
| 1645 | + if (artist.startsWith(normalizedQuery)) return true; |
| 1646 | + |
| 1647 | + // Check if query matches at start of any word in artist name |
| 1648 | + const words = artist.split(/\s+/); |
| 1649 | + return words.some((word) => word.startsWith(normalizedQuery)); |
| 1650 | + }); |
| 1651 | + |
| 1652 | + if (matchingTrack) { |
| 1653 | + // Select and scroll to the track |
| 1654 | + this.selectedTracks.clear(); |
| 1655 | + this.selectedTracks.add(matchingTrack.id); |
| 1656 | + this.scrollToTrack(matchingTrack.id); |
| 1657 | + } |
| 1658 | + }, |
| 1659 | + |
| 1660 | + /** |
| 1661 | + * Reset the type-to-jump debounce timer |
| 1662 | + */ |
| 1663 | + resetTypeDebounce() { |
| 1664 | + if (this._typeDebounceTimer) { |
| 1665 | + clearTimeout(this._typeDebounceTimer); |
| 1666 | + } |
| 1667 | + this._typeDebounceTimer = setTimeout(() => { |
| 1668 | + this._typeBuffer = ''; |
| 1669 | + this._typeDebounceTimer = null; |
| 1670 | + }, 500); // 500ms matches existing debounce patterns in codebase |
| 1671 | + }, |
| 1672 | + |
1567 | 1673 | /** |
1568 | 1674 | * Handle keyboard shortcuts |
1569 | 1675 | * @param {KeyboardEvent} event |
|
0 commit comments