diff --git a/app_test.go b/app_test.go index 788df1e..df10e86 100644 --- a/app_test.go +++ b/app_test.go @@ -98,7 +98,7 @@ func TestParseCSVString(t *testing.T) { func TestGetCommands(t *testing.T) { reg := NewCommandRegistry() cmds := reg.GetCommands() - if len(cmds) != 14 { + if len(cmds) != 15 { t.Errorf("expected 12 commands, got %d", len(cmds)) } // Check that all have IDs diff --git a/commands.go b/commands.go index 467a54e..8c62e2a 100644 --- a/commands.go +++ b/commands.go @@ -32,5 +32,6 @@ func (c *CommandRegistry) GetCommands() []Command { {ID: "open-right", Name: "Insert Column Right", Shortcut: ""}, {ID: "sort-asc", Name: "Sort A-Z", Shortcut: ""}, {ID: "sort-desc", Name: "Sort Z-A", Shortcut: ""}, + {ID: "sort-advanced", Name: "Sort Advanced", Shortcut: ""}, } } diff --git a/frontend/index.html b/frontend/index.html index c4d8580..ad7ed40 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -25,6 +25,20 @@
+ diff --git a/frontend/src/main.js b/frontend/src/main.js index e9a1e02..6213a51 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -26,6 +26,11 @@ const commandInput = $('#command-input'); const commandList = $('#command-list'); const overlay = $('#overlay'); const tableContainer = $('#table-container'); +const sortModal = $('#sort-modal'); +const sortInput = $('#sort-columns-input'); +const sortAutocomplete = $('#sort-autocomplete'); +const sortConfirmBtn = $('#sort-confirm-btn'); +const sortCancelBtn = $('#sort-cancel-btn'); // Default column width const DEFAULT_COL_WIDTH = 120; @@ -609,6 +614,7 @@ async function loadCommands() { { ID: 'open-right', Name: 'Insert Column Right', Shortcut: '' }, { ID: 'sort-asc', Name: 'Sort A-Z', Shortcut: '' }, { ID: 'sort-desc', Name: 'Sort Z-A', Shortcut: '' }, + { ID: 'sort-advanced', Name: 'Sort Advanced', Shortcut: '' }, ]; } } @@ -691,7 +697,10 @@ commandInput.addEventListener('keydown', (e) => { e.stopPropagation(); }); -overlay.addEventListener('click', closeCommandPalette); +overlay.addEventListener('click', () => { + if (!commandPalette.classList.contains('hidden')) closeCommandPalette(); + if (!sortModal.classList.contains('hidden')) closeSortAdvanced(); +}); // ===== Command execution ===== async function executeCommand(id) { @@ -710,6 +719,7 @@ async function executeCommand(id) { case 'open-right': doInsertColRight(); break; case 'sort-asc': doSort(true); break; case 'sort-desc': doSort(false); break; + case 'sort-advanced': openSortAdvanced(); break; } } @@ -936,6 +946,194 @@ function doSort(ascending) { setStatus(`Sorted by ${state.headers[col] || colLabel(col)} ${ascending ? 'A-Z' : 'Z-A'}`); } +// ===== Sort Advanced Modal ===== +let acIndex = -1; + +function openSortAdvanced() { + sortModal.classList.remove('hidden'); + overlay.classList.remove('hidden'); + sortInput.value = ''; + hideSortAutocomplete(); + sortInput.focus(); +} + +function closeSortAdvanced() { + sortModal.classList.add('hidden'); + overlay.classList.add('hidden'); + sortInput.value = ''; + hideSortAutocomplete(); +} + +function hideSortAutocomplete() { + sortAutocomplete.classList.add('hidden'); + sortAutocomplete.innerHTML = ''; + acIndex = -1; +} + +function getCurrentToken() { + const val = sortInput.value; + const cursor = sortInput.selectionStart; + const before = val.substring(0, cursor); + const lastComma = before.lastIndexOf(','); + return { + text: before.substring(lastComma + 1).trim(), + start: lastComma + 1, + cursorPos: cursor, + }; +} + +function getAlreadyChosen() { + const parts = sortInput.value.split(','); + const cursor = sortInput.selectionStart; + const before = sortInput.value.substring(0, cursor); + const tokenIdx = before.split(',').length - 1; + return parts + .map(p => p.trim().toLowerCase()) + .filter((_, i) => i !== tokenIdx) + .filter(p => p.length > 0); +} + +function updateSortAutocomplete() { + const { text } = getCurrentToken(); + const lower = text.toLowerCase(); + const chosen = getAlreadyChosen(); + + const matches = state.headers + .filter(h => h.toLowerCase().includes(lower)) + .filter(h => !chosen.includes(h.toLowerCase())) + .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); + + if (matches.length === 0 || (matches.length === 1 && matches[0].toLowerCase() === lower)) { + hideSortAutocomplete(); + return; + } + + sortAutocomplete.innerHTML = ''; + sortAutocomplete.classList.remove('hidden'); + acIndex = -1; + + matches.forEach((name, i) => { + const div = document.createElement('div'); + div.className = 'ac-item'; + div.textContent = name; + div.addEventListener('mousedown', (e) => { + e.preventDefault(); // keep focus on input + applySortCompletion(name); + }); + sortAutocomplete.appendChild(div); + }); +} + +function applySortCompletion(name) { + const val = sortInput.value; + const cursor = sortInput.selectionStart; + const before = val.substring(0, cursor); + const after = val.substring(cursor); + const lastComma = before.lastIndexOf(','); + const prefix = before.substring(0, lastComma + 1); + const spacing = lastComma >= 0 ? ' ' : ''; + const newVal = prefix + spacing + name + after; + sortInput.value = newVal; + const newCursor = (prefix + spacing + name).length; + sortInput.setSelectionRange(newCursor, newCursor); + hideSortAutocomplete(); + sortInput.focus(); +} + +function confirmSortAdvanced() { + const names = sortInput.value + .split(',') + .map(s => s.trim()) + .filter(s => s.length > 0); + + const colIndices = []; + for (const name of names) { + const idx = state.headers.findIndex( + h => h.toLowerCase() === name.toLowerCase() + ); + if (idx === -1) { + setStatus(`Unknown column: "${name}"`); + return; + } + colIndices.push(idx); + } + + if (colIndices.length === 0) { + setStatus('No columns specified'); + return; + } + + state.rows.sort((a, b) => { + for (const col of colIndices) { + const va = (a[col] || '').toLowerCase(); + const vb = (b[col] || '').toLowerCase(); + const cmp = va.localeCompare(vb, undefined, { numeric: true }); + if (cmp !== 0) return cmp; + } + return 0; + }); + + closeSortAdvanced(); + render(); + setStatus('Sorted by ' + names.join(', ')); +} + +sortInput.addEventListener('input', updateSortAutocomplete); +sortInput.addEventListener('focus', updateSortAutocomplete); +sortInput.addEventListener('blur', () => { + // Small delay to allow mousedown on ac-item to fire + setTimeout(hideSortAutocomplete, 150); +}); + +sortInput.addEventListener('keydown', (e) => { + const items = sortAutocomplete.querySelectorAll('.ac-item'); + + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + if (!sortAutocomplete.classList.contains('hidden')) { + hideSortAutocomplete(); + } else { + closeSortAdvanced(); + } + return; + } + + if (e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + if (acIndex >= 0 && items[acIndex]) { + applySortCompletion(items[acIndex].textContent); + } else { + confirmSortAdvanced(); + } + return; + } + + if (e.key === 'ArrowDown' && items.length > 0) { + e.preventDefault(); + if (acIndex >= 0) items[acIndex]?.classList.remove('active'); + acIndex = (acIndex + 1) % items.length; + items[acIndex]?.classList.add('active'); + items[acIndex]?.scrollIntoView({ block: 'nearest' }); + return; + } + + if (e.key === 'ArrowUp' && items.length > 0) { + e.preventDefault(); + if (acIndex >= 0) items[acIndex]?.classList.remove('active'); + acIndex = (acIndex - 1 + items.length) % items.length; + items[acIndex]?.classList.add('active'); + items[acIndex]?.scrollIntoView({ block: 'nearest' }); + return; + } + + e.stopPropagation(); +}); + +sortConfirmBtn.addEventListener('click', confirmSortAdvanced); +sortCancelBtn.addEventListener('click', closeSortAdvanced); + // ===== File loading ===== async function loadFile(filePath) { try { diff --git a/frontend/src/style.css b/frontend/src/style.css index b858735..ea72bad 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -273,6 +273,124 @@ td.cursor-cell { border-radius: 3px; } +/* Sort Advanced Modal */ +#sort-modal { + position: fixed; + top: 80px; + left: 50%; + transform: translateX(-50%); + width: 460px; + background: #fff; + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 8px 32px rgba(0,0,0,0.2); + z-index: 100; + overflow: visible; +} + +#sort-modal.hidden { + display: none; +} + +.modal-title { + padding: 12px 16px; + font-size: 15px; + font-weight: 600; + border-bottom: 1px solid var(--border); +} + +.modal-body { + padding: 12px 16px; +} + +.modal-body label { + display: block; + margin-bottom: 6px; + font-size: 13px; + color: #555; +} + +#sort-input-wrap { + position: relative; +} + +#sort-columns-input { + width: 100%; + padding: 8px 10px; + font-size: 14px; + font-family: inherit; + border: 1px solid var(--border); + border-radius: 4px; + outline: none; +} + +#sort-columns-input:focus { + border-color: var(--selected-border); + box-shadow: 0 0 0 2px rgba(34,102,204,0.15); +} + +#sort-autocomplete { + position: absolute; + left: 0; + right: 0; + top: 100%; + margin-top: 2px; + background: #fff; + border: 1px solid var(--border); + border-radius: 4px; + box-shadow: 0 4px 12px rgba(0,0,0,0.12); + max-height: 180px; + overflow-y: auto; + z-index: 110; +} + +#sort-autocomplete.hidden { + display: none; +} + +.ac-item { + padding: 6px 10px; + cursor: pointer; + font-size: 13px; +} + +.ac-item:hover, +.ac-item.active { + background: var(--selected-bg); +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 10px 16px; + border-top: 1px solid var(--border); +} + +.modal-btn { + padding: 6px 16px; + font-size: 13px; + font-family: inherit; + border: 1px solid var(--border); + border-radius: 4px; + background: #fff; + cursor: pointer; +} + +.modal-btn:hover { + background: #f5f5f5; +} + +.modal-btn-primary { + background: var(--selected-border); + color: #fff; + border-color: var(--selected-border); +} + +.modal-btn-primary:hover { + background: #1a54a9; +} + /* Drag over style */ #table-container.drag-over { background: var(--selected-bg);