From 401f03f58b1a96a430b439f44289f1d05d8147bf Mon Sep 17 00:00:00 2001 From: Daniel Dada Date: Fri, 6 Feb 2026 16:33:10 +0300 Subject: [PATCH] removed list manager window --- _locales/en/messages.json | 14 +- list-manager/list-manager.css | 506 ---------- list-manager/list-manager.html | 105 -- popup/popup.css | 22 +- popup/popup.html | 47 +- src/background.ts | 16 - src/list-manager/ListManagerController.ts | 1054 --------------------- src/list-manager/list-manager.ts | 40 - src/popup/PopupController.ts | 240 +++-- 9 files changed, 181 insertions(+), 1863 deletions(-) delete mode 100644 list-manager/list-manager.css delete mode 100644 list-manager/list-manager.html delete mode 100644 src/list-manager/ListManagerController.ts delete mode 100644 src/list-manager/list-manager.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 51dbeb8..3275cf5 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -42,16 +42,16 @@ "message": "Delete" }, "disable_selected": { - "message": "Disable" + "message": "Disable selected" }, "enable_selected": { - "message": "Enable" + "message": "Enable selected" }, "import_list": { - "message": "Import JSON" + "message": "Import list" }, "export_list": { - "message": "Export JSON" + "message": "Export list" }, "import_list_list_manager": { "message": "Import list" @@ -203,6 +203,12 @@ "copy_to_list": { "message": "Copy to list" }, + "move_selected": { + "message": "Move selected" + }, + "copy_selected": { + "message": "Copy selected" + }, "no_other_lists": { "message": "No other lists" }, diff --git a/list-manager/list-manager.css b/list-manager/list-manager.css deleted file mode 100644 index 691e2fd..0000000 --- a/list-manager/list-manager.css +++ /dev/null @@ -1,506 +0,0 @@ -@import url('../shared/fonts.css'); -@import url('../shared/colors.css'); -@import url('../shared/base.css'); -@import url('../shared/layout.css'); -@import url('../shared/word-list.css'); -@import url('../shared/ui-components.css'); - -html { - height: 100%; -} - -body { - height: 100%; - overflow: hidden; -} - -.app { - display: flex; - flex-direction: column; - height: 100vh; - overflow: hidden; -} - -.app button { - border-radius: 6px; -} - -.app .icon-btn { - min-width: 28px; - min-height: 28px; - border-radius: 6px; -} - -/* List-manager–specific: compact header padding; shared .app-header does the rest */ -.topbar.app-header { - padding: 8px 16px; -} - -.title.app-header-content { - gap: 10px; -} - -.topbar-actions .icon-toggle { - width: 28px; - height: 28px; - padding: 0; - border-radius: 6px; - display: inline-flex; - align-items: center; - justify-content: center; -} - -.layout { - display: grid; - grid-template-columns: minmax(280px, 25%) 1fr; - gap: 12px; - padding: 12px; - flex: 1; - overflow: hidden; - min-height: 0; -} - -.panel { - background: var(--section-bg); - border: 1px solid var(--input-border); - border-radius: 8px; - padding: 12px; - display: flex; - flex-direction: column; - gap: 10px; - overflow: hidden; - min-height: 0; -} - -.panel-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; -} - -.lists-panel .panel-header { - flex-direction: column; - align-items: flex-start; - gap: 6px; -} - -.lists-panel .panel-header h2 { - margin-top: 0; -} - -.panel-header h2 { - margin: 0; - font-size: 13px; - font-weight: 600; - color: var(--text-color); -} - -.panel-actions { - display: flex; - gap: 6px; - flex-wrap: wrap; -} - -.lists-panel .panel-actions { - justify-content: flex-start; - width: 100%; -} - -.lists-panel .panel-actions button { - padding: 4px 8px; - font-size: 12px; - border-radius: 6px; - min-height: 28px; -} - -.lists-panel .panel-actions button.ghost { - background: var(--input-bg); - border: 1px solid var(--input-border); - color: var(--text-color); -} - -.lists-panel .panel-actions button.ghost:hover { - background: var(--section-bg); -} - -.panel-actions.secondary { - margin-top: 0; - gap: 6px; -} - -.panel-actions.secondary button { - padding: 4px 8px; - font-size: 12px; - border-radius: 6px; - min-height: 28px; -} - -.list-hint { - font-size: 11px; - opacity: 0.6; - margin-top: -2px; -} - -.selection-hint { - font-size: 12px; - color: var(--text-color); - opacity: 0.6; - margin-top: 4px; - font-style: italic; - text-align: center; -} - -.lists { - display: flex; - flex-direction: column; - gap: 8px; - overflow-y: auto; - padding-right: 4px; - flex: 1; - min-height: 0; -} - -.list-item { - background: var(--input-bg); - border: 2px solid transparent; - border-radius: 8px; - padding: 8px 10px; - display: grid; - grid-template-columns: 1fr auto; - gap: 8px; - align-items: center; - cursor: pointer; - transition: all 0.2s ease; - position: relative; -} - -.list-item:hover { - background: var(--section-bg); - border-color: var(--input-border); -} - -.list-item.active { - border-color: var(--accent); - box-shadow: var(--shadow-sm); - background: var(--section-bg); -} - -.list-item.selected { - background: var(--section-bg); - border-color: rgba(204, 106, 42, 0.5); -} - -html.dark .list-item.selected, -body.dark .list-item.selected { - border-color: rgba(255, 140, 0, 0.5); -} - -.list-item.selected.active { - border-color: var(--accent); -} - -.list-item.drag-over { - border-color: var(--accent-hover); - transform: scale(1.02); -} - -.list-meta { - display: flex; - flex-direction: column; - gap: 4px; -} - -.list-name { - font-weight: 600; - font-size: 13px; -} - -.list-stats { - font-size: 11px; - opacity: 0.7; -} - -.list-badge { - font-size: 11px; - padding: 2px 6px; - border-radius: 999px; - border: 1px solid var(--input-border); - opacity: 0.8; - cursor: pointer; - user-select: none; -} - -.list-badge:hover { - opacity: 1; -} - -.list-badge:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; -} - -.details-panel { - display: flex; - flex-direction: column; - gap: 12px; - min-height: 0; - overflow: hidden; -} - -.list-settings { - display: none; - gap: 8px; - padding: 8px 10px; - background: var(--input-bg); - border-radius: 6px; - border: 1px solid var(--input-border); -} - -.list-settings.expanded { - display: flex; - flex-direction: column; -} - -.list-settings input[type="text"] { - width: 100%; - padding: 6px 10px; - font-size: 13px; - border-radius: 6px; -} - -.color-row { - display: flex; - gap: 8px; - align-items: center; -} - -.compact-label { - display: flex; - align-items: center; - gap: 6px; - font-size: 12px; - flex: 1; -} - -.compact-label span { - min-width: 24px; - font-weight: 500; - opacity: 0.8; -} - -.compact-label input[type="color"] { - flex: 1; - height: 32px; -} - -.compact-btn { - padding: 4px 10px; - min-width: auto; - height: 32px; - border-radius: 6px; - font-size: 13px; -} - -.compact-header { - flex-direction: row; - align-items: center; - justify-content: space-between; -} - -.list-title-section { - display: flex; - align-items: center; - gap: 8px; -} - -.list-title-section h2 { - margin: 0; - font-size: 13px; - font-weight: 600; - max-width: 300px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - - -.word-controls { - display: flex; - flex-direction: column; - gap: 8px; -} - -.add-words { - display: flex; - gap: 10px; - align-items: flex-start; -} - -/* List-manager–specific: add-words button width; shared word-list.css does the rest */ -.add-words button { - min-width: 100px; - height: 32px; - border-radius: 6px; - font-size: 13px; - flex-shrink: 0; -} - -.word-controls input[type="text"], -.word-controls select { - flex: 1; - min-width: 140px; - min-height: 32px; - padding: 0 10px; - font-size: 14px; - border-radius: 8px; -} - -/* Search/move row: align input, select and buttons to same height */ -.word-controls .row:has(#wordSearch) button { - height: 32px; -} - -.word-item .word-item-eye-toggle { - flex-shrink: 0; -} - -.app input[type="color"] { - padding: 0; - height: 32px; - border-radius: 6px; - border: 1px solid var(--input-border); - cursor: pointer; -} - -.app input[type="color"]:hover { - border-color: var(--accent); -} - -/* Subdued primary for compact buttons (e.g. Apply list settings) */ -.compact-btn.primary { - background: rgba(255, 140, 0, 0.1); - color: var(--accent); - border: 1px solid rgba(255, 140, 0, 0.35); -} - -.compact-btn.primary:hover { - background: rgba(255, 140, 0, 0.18); - border-color: rgba(255, 140, 0, 0.5); -} - -html.dark .compact-btn.primary, -body.dark .compact-btn.primary { - background: rgba(255, 140, 0, 0.12); - border-color: rgba(255, 140, 0, 0.4); -} - -html.dark .compact-btn.primary:hover, -body.dark .compact-btn.primary:hover { - background: rgba(255, 140, 0, 0.2); - border-color: rgba(255, 140, 0, 0.55); -} - -.colors input[type="color"] { - width: 100%; -} - -.stats { - font-size: 12px; - opacity: 0.7; -} - -.pagination-container { - display: flex; - flex-direction: row; - align-items: center; - justify-content: space-between; - gap: 10px; - padding: 6px 10px; - background: var(--section-bg); - border: 1px solid var(--input-border); - border-radius: 8px; - flex-shrink: 0; - flex-wrap: wrap; -} - -.pagination-info { - font-size: 12px; - opacity: 0.8; - white-space: nowrap; -} - -.pagination-controls { - display: flex; - align-items: center; - gap: 6px; -} - -.pagination-btn { - background: var(--input-bg); - border: 1px solid var(--input-border); - color: var(--text-color); - border-radius: 6px; - padding: 4px 6px; - cursor: pointer; - transition: all 0.2s ease; - display: flex; - align-items: center; - justify-content: center; - min-width: 28px; - min-height: 28px; - font-size: 12px; -} - -.pagination-btn:hover:not(:disabled) { - background: var(--section-bg); - border-color: var(--accent); -} - -.pagination-btn:disabled { - opacity: 0.4; - cursor: not-allowed; -} - -.pagination-pages { - display: flex; - align-items: center; - padding: 0 6px; -} - -.page-info { - font-size: 12px; - opacity: 0.9; - font-weight: 500; -} - -.page-size-controls { - display: flex; - align-items: center; - gap: 6px; - font-size: 12px; -} - -.page-size-controls label { - opacity: 0.8; - white-space: nowrap; -} - -.page-size-select { - background: var(--input-bg); - color: var(--text-color); - border: 1px solid var(--input-border); - border-radius: 6px; - padding: 3px 6px; - font-size: 12px; - min-width: 55px; -} - -.page-size-select:focus { - outline: none; - border-color: var(--accent); -} - - - -/* Removed single column layout - minimum width enforced for two-column layout */ diff --git a/list-manager/list-manager.html b/list-manager/list-manager.html deleted file mode 100644 index 9ac6770..0000000 --- a/list-manager/list-manager.html +++ /dev/null @@ -1,105 +0,0 @@ - - - - - - - Goose Highlighter - List Manager - - - - - -
-
-
- -
-
Goose Highlighter
-
List Manager
-
-
-
- - - - -
- -
- -
-
-
-

Lists

-
- - - -
-
-
Drag lists to reorder • Ctrl+Click for multi-select
-
-
- -
-
-
-

Selected List

- -
-
0 words
-
- - -
-
- - -
-
- - - - - -
-
- - - - -
-
Click to select • Ctrl/Cmd+Click for multi-select • Drag words to lists to copy
-
- -
-
-
-
-
- - - - - diff --git a/popup/popup.css b/popup/popup.css index 22f57e3..ef586bd 100644 --- a/popup/popup.css +++ b/popup/popup.css @@ -366,8 +366,14 @@ body { color: var(--danger); } -.manage-lists-btn { - width: 100%; +.list-export-import-row { + display: flex; + gap: 8px; + align-items: center; +} + +.list-export-import-btn { + flex: 1; height: 28px; display: flex; align-items: center; @@ -383,11 +389,11 @@ body { color: var(--text-color); } -.manage-lists-btn:hover { +.list-export-import-btn:hover { background: var(--section-bg); } -.manage-lists-btn i { +.list-export-import-btn i { font-size: 14px; } @@ -752,6 +758,14 @@ body { cursor: default; } +.word-item-menu-item.danger { + color: var(--danger); +} + +.word-item-menu-item.danger:hover:not(.disabled) { + background: rgba(239, 68, 68, 0.1); +} + .word-item-menu-item i { font-size: 12px; opacity: 0.8; diff --git a/popup/popup.html b/popup/popup.html index 2a2e4d0..f45c3bc 100644 --- a/popup/popup.html +++ b/popup/popup.html @@ -85,11 +85,18 @@ - - + +
+ + + +
@@ -169,25 +176,6 @@
- -
- - - - - -
-
@@ -288,17 +276,6 @@
-
- - - -
diff --git a/src/background.ts b/src/background.ts index 41b7225..fb1bfdc 100644 --- a/src/background.ts +++ b/src/background.ts @@ -8,7 +8,6 @@ class BackgroundService { private initialize(): void { this.setupTabUpdateListener(); this.setupInstallListener(); - this.setupContextMenu(); } private setupTabUpdateListener(): void { @@ -30,21 +29,6 @@ class BackgroundService { if (!data.exceptionsList) { await StorageService.update('exceptionsList', []); } - chrome.contextMenus.removeAll(() => { - chrome.contextMenus.create({ - id: 'manage-lists', - title: 'Manage Lists', - contexts: ['action'] - }); - }); - }); - } - - private setupContextMenu(): void { - chrome.contextMenus.onClicked.addListener(async (info) => { - if (info.menuItemId === 'manage-lists') { - await chrome.tabs.create({ url: chrome.runtime.getURL('list-manager/list-manager.html') }); - } }); } diff --git a/src/list-manager/ListManagerController.ts b/src/list-manager/ListManagerController.ts deleted file mode 100644 index 589e003..0000000 --- a/src/list-manager/ListManagerController.ts +++ /dev/null @@ -1,1054 +0,0 @@ -import { HighlightList, HighlightWord, ExportData } from '../types.js'; -import { StorageService } from '../services/StorageService.js'; -import { MessageService } from '../services/MessageService.js'; -import { DOMUtils } from '../utils/DOMUtils.js'; - -export class ListManagerController { - private lists: HighlightList[] = []; - private currentListIndex = 0; - private selectedLists = new Set(); - private selectedWords = new Set(); - private wordSearchQuery = ''; - private isReloading = false; - private currentPage = 1; - private pageSize = 100; - private totalWords = 0; - -async initialize(): Promise { - await this.loadData(); - this.setupEventListeners(); - this.setupTheme(); - this.render(); - this.setupStorageSync(); - } - - private async loadData(): Promise { - const data = await StorageService.get(); - this.lists = data.lists || []; - - if (this.lists.length === 0) { - this.lists.push({ - id: Date.now(), - name: chrome.i18n.getMessage('default_list_name') || 'Default List', - background: '#ffff00', - foreground: '#000000', - active: true, - words: [] - }); - } - - this.currentListIndex = Math.min(this.currentListIndex, this.lists.length - 1); - } - - private setupEventListeners(): void { - document.getElementById('newListBtn')?.addEventListener('click', () => this.createList()); - document.getElementById('duplicateListBtn')?.addEventListener('click', () => this.duplicateCurrentList()); - document.getElementById('mergeListsBtn')?.addEventListener('click', () => this.mergeSelectedLists()); - document.getElementById('deleteListsBtn')?.addEventListener('click', () => this.deleteSelectedLists()); - document.getElementById('editListNameBtn')?.addEventListener('click', () => this.toggleListSettings()); - document.getElementById('applyListSettingsBtn')?.addEventListener('click', () => this.applyListSettings()); - document.getElementById('importListBtn')?.addEventListener('click', () => this.triggerImport()); - document.getElementById('exportListBtn')?.addEventListener('click', () => this.exportCurrentList()); - - const importFileInput = document.getElementById('importFileInput') as HTMLInputElement; - importFileInput?.addEventListener('change', (e) => this.handleImportFile(e)); - - document.getElementById('selectAllWordsBtn')?.addEventListener('click', () => this.selectAllWords()); - document.getElementById('clearSelectedWordsBtn')?.addEventListener('click', () => this.clearSelectedWords()); - document.getElementById('enableWordsBtn')?.addEventListener('click', () => this.setSelectedWordsActive(true)); - document.getElementById('disableWordsBtn')?.addEventListener('click', () => this.setSelectedWordsActive(false)); - document.getElementById('deleteWordsBtn')?.addEventListener('click', () => this.deleteSelectedWords()); - document.getElementById('addWordsBtn')?.addEventListener('click', () => this.addWordsFromBulkInput()); - - document.getElementById('moveWordsBtn')?.addEventListener('click', () => this.moveOrCopySelectedWords(false)); - document.getElementById('copyWordsBtn')?.addEventListener('click', () => this.moveOrCopySelectedWords(true)); - -const wordSearch = document.getElementById('wordSearch') as HTMLInputElement; - wordSearch.addEventListener('input', (e) => { - this.wordSearchQuery = (e.target as HTMLInputElement).value; - this.currentPage = 1; - this.renderWords(); - }); - - const listsContainer = document.getElementById('listsContainer'); - listsContainer?.addEventListener('click', (e) => this.handleListClick(e)); - listsContainer?.addEventListener('keydown', (e) => this.handleListsKeydown(e)); - listsContainer?.addEventListener('dragstart', (e) => this.handleDragStart(e)); - listsContainer?.addEventListener('dragover', (e) => this.handleDragOver(e)); - listsContainer?.addEventListener('drop', (e) => this.handleDrop(e)); - listsContainer?.addEventListener('dragend', () => this.clearDragState()); - - const wordList = document.getElementById('wordList'); - wordList?.addEventListener('click', (e) => this.handleWordListClick(e)); - wordList?.addEventListener('change', (e) => this.handleWordListChange(e)); - wordList?.addEventListener('keydown', (e) => this.handleWordListKeydown(e)); - wordList?.addEventListener('blur', (e) => this.handleWordListBlur(e), true); - wordList?.addEventListener('dragstart', (e) => this.handleWordDragStart(e)); - wordList?.addEventListener('dragend', () => this.clearDragState()); - } - - private setupStorageSync(): void { - chrome.storage.onChanged.addListener((changes, areaName) => { - if (areaName !== 'local') return; - if (!changes.lists) return; - if (this.isReloading) return; - this.reloadFromStorage(); - }); - } - - private async reloadFromStorage(): Promise { - this.isReloading = true; - await this.loadData(); - this.selectedLists.clear(); - this.selectedWords.clear(); - this.render(); - this.isReloading = false; - } - - private createList(): void { - this.lists.push({ - id: Date.now(), - name: chrome.i18n.getMessage('new_list_name') || 'New List', - background: '#ffff00', - foreground: '#000000', - active: true, - words: [] - }); - this.currentListIndex = this.lists.length - 1; - this.selectedLists.clear(); - this.save(); - } - - private duplicateCurrentList(): void { - const toDuplicate = this.getEffectiveSelectedListIndices(); - if (toDuplicate.length === 0) return; - const sortedDesc = [...toDuplicate].sort((a, b) => b - a); - sortedDesc.forEach(index => { - const list = this.lists[index]; - if (!list) return; - const newList: HighlightList = { - id: Date.now() + Math.random(), - name: `${list.name} (Copy)`, - background: list.background, - foreground: list.foreground, - active: list.active, - words: list.words.map(word => ({ ...word })) - }; - this.lists.splice(index + 1, 0, newList); - }); - const firstInsertedIndex = Math.min(...toDuplicate) + 1; - this.currentListIndex = Math.min(firstInsertedIndex, this.lists.length - 1); - this.selectedLists.clear(); - this.save(); - } - - private mergeSelectedLists(): void { - const selected = this.getEffectiveSelectedListIndices(); - if (selected.length < 2) { - alert(chrome.i18n.getMessage('merge_lists_min_two') || 'Select at least two lists to merge.'); - return; - } - - const targetIndex = selected.includes(this.currentListIndex) ? this.currentListIndex : selected[0]; - const target = this.lists[targetIndex]; - if (!target) return; - - const confirmMessage = chrome.i18n.getMessage('merge_lists_confirm') - ?.replace('{count}', String(selected.length - 1)) - .replace('{target}', target.name) - || `Merge ${selected.length - 1} list(s) into "${target.name}"? Source lists will be removed.`; - if (!confirm(confirmMessage)) return; - - const sourceIndices = selected.filter(index => index !== targetIndex).sort((a, b) => b - a); - sourceIndices.forEach(index => { - const source = this.lists[index]; - if (source) { - target.words.push(...source.words.map(word => ({ ...word }))); - this.lists.splice(index, 1); - } - }); - - this.currentListIndex = Math.min(targetIndex, this.lists.length - 1); - this.selectedLists.clear(); - this.save(); - } - - private deleteSelectedLists(): void { - const selected = this.getEffectiveSelectedListIndices(); - if (selected.length === 0) return; - const confirmMessage = chrome.i18n.getMessage('delete_lists_confirm') - ?.replace('{count}', String(selected.length)) - || `Delete ${selected.length} selected list(s)?`; - if (!confirm(confirmMessage)) return; - selected.sort((a, b) => b - a).forEach(index => this.lists.splice(index, 1)); - - if (this.lists.length === 0) { - this.createList(); - return; - } - - this.currentListIndex = Math.min(this.currentListIndex, this.lists.length - 1); - this.selectedLists.clear(); - this.save(); - } - - private toggleListActive(index: number): void { - const list = this.lists[index]; - if (list) { - list.active = !list.active; - this.save(); - } - } - - private applyListSettings(): void { - const list = this.lists[this.currentListIndex]; - if (!list) return; - - const listName = document.getElementById('listName') as HTMLInputElement; - const listBg = document.getElementById('listBg') as HTMLInputElement; - const listFg = document.getElementById('listFg') as HTMLInputElement; - - list.name = listName.value; - list.background = listBg.value; - list.foreground = listFg.value; - - this.toggleListSettings(); - this.save(); - } - - private toggleListSettings(): void { - const panel = document.getElementById('listSettingsPanel'); - if (!panel) return; - - if (panel.classList.contains('expanded')) { - panel.classList.remove('expanded'); - } else { - panel.classList.add('expanded'); - const listName = document.getElementById('listName') as HTMLInputElement; - listName?.focus(); - listName?.select(); - } - } - - private exportCurrentList(): void { - const list = this.lists[this.currentListIndex]; - if (!list) return; - - const exportData: ExportData = { - lists: [list], - exceptionsList: [] - }; - - const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `highlight-list-${this.sanitizeFileName(list.name)}.json`; - a.click(); - URL.revokeObjectURL(url); - } - - private triggerImport(): void { - const fileInput = document.getElementById('importFileInput') as HTMLInputElement; - if (fileInput) { - fileInput.value = ''; - fileInput.click(); - } - } - - private async handleImportFile(event: Event): Promise { - const input = event.target as HTMLInputElement; - const file = input.files?.[0]; - if (!file) return; - - try { - const text = await file.text(); - const data = JSON.parse(text); - - if (!this.validateImportData(data)) { - alert(chrome.i18n.getMessage('invalid_import_format') || 'Invalid file format. Please select a valid Goose Highlighter export file.'); - return; - } - - this.importLists(data); - } catch (error) { - console.error('Import error:', error); - alert(chrome.i18n.getMessage('import_failed') || 'Failed to import file. Please ensure it is a valid JSON file.'); - alert('Failed to import file. Please ensure it is a valid JSON file.'); - } - } - - private validateImportData(data: any): data is ExportData { - if (!data || typeof data !== 'object') return false; - if (!Array.isArray(data.lists)) return false; - - return data.lists.every((list: any) => - list && - typeof list === 'object' && - typeof list.name === 'string' && - typeof list.background === 'string' && - typeof list.foreground === 'string' && - typeof list.active === 'boolean' && - Array.isArray(list.words) && - list.words.every((word: any) => - word && - typeof word === 'object' && - typeof word.wordStr === 'string' && - typeof word.background === 'string' && - typeof word.foreground === 'string' && - typeof word.active === 'boolean' - ) - ); - } - - private importLists(data: ExportData): void { - const importedLists = data.lists.map(list => ({ - ...list, - id: Date.now() + Math.random(), - name: this.getUniqueListName(list.name) - })); - - const count = importedLists.length; - const wordCount = importedLists.reduce((sum, list) => sum + list.words.length, 0); - - if (!confirm(`Import ${count} list(s) with ${wordCount} total word(s)?`)) return; - - this.lists.push(...importedLists); - this.currentListIndex = this.lists.length - 1; - this.selectedLists.clear(); - this.save(); - - alert(`Successfully imported ${count} list(s) with ${wordCount} word(s).`); - } - - private getUniqueListName(baseName: string): string { - const existingNames = new Set(this.lists.map(list => list.name)); - if (!existingNames.has(baseName)) return baseName; - - let counter = 1; - let newName = `${baseName} (${counter})`; - while (existingNames.has(newName)) { - counter++; - newName = `${baseName} (${counter})`; - } - return newName; - } - - private sanitizeFileName(name: string): string { - return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'list'; - } - - private selectAllWords(): void { - const list = this.lists[this.currentListIndex]; - if (!list) return; - const entries = this.getFilteredWordEntries(list); - entries.forEach(entry => this.selectedWords.add(entry.index)); - this.renderWords(); - } - - private clearSelectedWords(): void { - this.selectedWords.clear(); - this.renderWords(); - } - - private setSelectedWordsActive(active: boolean): void { - const list = this.lists[this.currentListIndex]; - if (!list) return; - this.selectedWords.forEach(index => { - if (list.words[index]) list.words[index].active = active; - }); - this.save(); - } - - private deleteSelectedWords(): void { - const list = this.lists[this.currentListIndex]; - if (!list || this.selectedWords.size === 0) return; - const confirmMessage = chrome.i18n.getMessage('confirm_delete_words') - ?.replace('{count}', String(this.selectedWords.size)) - || `Delete ${this.selectedWords.size} selected word(s)?`; - if (!confirm(confirmMessage)) return; - - list.words = list.words.filter((_, i) => !this.selectedWords.has(i)); - this.selectedWords.clear(); - this.save(); - } - - private addWordsFromBulkInput(): void { - const list = this.lists[this.currentListIndex]; - if (!list) return; - - const textarea = document.getElementById('bulkAddWords') as HTMLTextAreaElement | null; - if (!textarea) return; - - const words = textarea.value - .split(/\n+/) - .map(word => word.trim()) - .filter(Boolean); - - if (words.length === 0) return; - - words.forEach(word => { - list.words.push({ - wordStr: word, - background: '', - foreground: '', - active: true - }); - }); - - textarea.value = ''; - this.save(); - } - - private moveOrCopySelectedWords(copyOnly: boolean): void { - const list = this.lists[this.currentListIndex]; - if (!list) return; - if (this.selectedWords.size === 0) return; - - const targetSelect = document.getElementById('targetListSelect') as HTMLSelectElement; - const targetIndex = Number(targetSelect.value); - if (Number.isNaN(targetIndex) || targetIndex === this.currentListIndex) return; - - const targetList = this.lists[targetIndex]; - if (!targetList) return; - - const selectedIndices = Array.from(this.selectedWords).sort((a, b) => a - b); - const wordsToMove = selectedIndices.map(index => list.words[index]).filter(Boolean); - - if (copyOnly) { - targetList.words.push(...wordsToMove.map(word => ({ ...word }))); - } else { - targetList.words.push(...wordsToMove.map(word => ({ ...word }))); - list.words = list.words.filter((_, i) => !this.selectedWords.has(i)); - } - - this.selectedWords.clear(); - this.save(); - } - - private handleListClick(event: Event): void { - const target = event.target as HTMLElement; - const listItem = target.closest('.list-item') as HTMLElement | null; - if (!listItem) return; - - const index = Number(listItem.dataset.index); - if (Number.isNaN(index)) return; - - // Click on active/paused badge toggles that list's active state - if (target.closest('.list-badge')) { - event.preventDefault(); - event.stopPropagation(); - this.toggleListActive(index); - return; - } - - const mouseEvent = event as MouseEvent; - // Ctrl/Cmd + click for multi-select - if (mouseEvent.ctrlKey || mouseEvent.metaKey) { - if (this.selectedLists.has(index)) { - this.selectedLists.delete(index); - } else { - this.selectedLists.add(index); - } - this.renderLists(); - return; - } - - // Regular click - set as current and clear multi-selection - this.currentListIndex = index; - this.selectedLists.clear(); - this.selectedWords.clear(); - this.currentPage = 1; // Reset to first page when selecting a list - this.render(); - } - - private handleListsKeydown(event: KeyboardEvent): void { - const target = event.target as HTMLElement; - const badge = target.closest('.list-badge'); - if (!badge) return; - const listItem = badge.closest('.list-item') as HTMLElement | null; - if (!listItem) return; - if (event.key !== 'Enter' && event.key !== ' ') return; - event.preventDefault(); - const index = Number(listItem.dataset.index); - if (!Number.isNaN(index)) this.toggleListActive(index); - } - - private handleDragStart(event: DragEvent): void { - const target = (event.target as HTMLElement).closest('.list-item') as HTMLElement | null; - if (!target) return; - const index = Number(target.dataset.index); - if (Number.isNaN(index)) return; - - event.dataTransfer?.setData('text/plain', JSON.stringify({ type: 'list', index })); - event.dataTransfer?.setDragImage(target, 10, 10); - } - - private handleDragOver(event: DragEvent): void { - event.preventDefault(); - const target = (event.target as HTMLElement).closest('.list-item') as HTMLElement | null; - if (!target) { - this.clearDragState(); - return; - } - - // Clear drag state from all items first - this.clearDragState(); - - // Check if we're dragging words or lists - const data = event.dataTransfer?.types.includes('text/plain'); - if (data) { - target.classList.add('drag-over'); - } - } - - private handleDrop(event: DragEvent): void { - event.preventDefault(); - const target = (event.target as HTMLElement).closest('.list-item') as HTMLElement | null; - if (!target) return; - - const targetIndex = Number(target.dataset.index); - if (Number.isNaN(targetIndex)) { - this.clearDragState(); - return; - } - - try { - const dataStr = event.dataTransfer?.getData('text/plain'); - if (!dataStr) { - this.clearDragState(); - return; - } - - const data = JSON.parse(dataStr); - - // Handle word drag - if (data.type === 'words') { - this.dropWordsOnList(data.wordIndices, targetIndex); - this.clearDragState(); - return; - } - - // Handle list drag (reordering) - if (data.type === 'list') { - const sourceIndex = data.index; - if (sourceIndex === targetIndex) { - this.clearDragState(); - return; - } - - const [moved] = this.lists.splice(sourceIndex, 1); - this.lists.splice(targetIndex, 0, moved); - - if (this.currentListIndex === sourceIndex) { - this.currentListIndex = targetIndex; - } else if (sourceIndex < this.currentListIndex && targetIndex >= this.currentListIndex) { - this.currentListIndex -= 1; - } else if (sourceIndex > this.currentListIndex && targetIndex <= this.currentListIndex) { - this.currentListIndex += 1; - } - - this.selectedLists.clear(); - this.save(); - } - } catch (error) { - console.error('Drop error:', error); - } - - this.clearDragState(); - } - - private clearDragState(): void { - document.querySelectorAll('.list-item.drag-over').forEach(item => item.classList.remove('drag-over')); - } - - private handleWordDragStart(event: DragEvent): void { - const target = (event.target as HTMLElement).closest('.word-item') as HTMLElement | null; - if (!target) return; - - const index = Number(target.dataset.index); - if (Number.isNaN(index)) return; - - // If dragging a selected word, drag all selected words - let wordIndices: number[]; - if (this.selectedWords.has(index)) { - wordIndices = Array.from(this.selectedWords); - } else { - wordIndices = [index]; - } - - event.dataTransfer?.setData('text/plain', JSON.stringify({ type: 'words', wordIndices })); - if (event.dataTransfer) { - event.dataTransfer.effectAllowed = 'move'; - } - } - - private dropWordsOnList(wordIndices: number[], targetListIndex: number): void { - const sourceList = this.lists[this.currentListIndex]; - const targetList = this.lists[targetListIndex]; - - if (!sourceList || !targetList) return; - if (targetListIndex === this.currentListIndex) return; // Can't drop on same list - - const wordsToMove = wordIndices - .map(index => sourceList.words[index]) - .filter(Boolean) - .map(word => ({ ...word })); - - if (wordsToMove.length === 0) return; - - targetList.words.push(...wordsToMove); - const indicesToRemove = new Set(wordIndices); - sourceList.words = sourceList.words.filter((_, i) => !indicesToRemove.has(i)); - wordIndices.forEach(i => this.selectedWords.delete(i)); - this.save(); - - const count = wordsToMove.length; - const message = `Moved ${count} word${count > 1 ? 's' : ''} to "${targetList.name}"`; - console.log(message); - } - - private handleWordListClick(event: Event): void { - const target = event.target as HTMLElement; - const list = this.lists[this.currentListIndex]; - if (!list) return; - - const editBtn = target.closest('.edit-word-btn') as HTMLElement | null; - if (editBtn) { - event.stopPropagation(); - const index = Number(editBtn.dataset.index); - if (Number.isNaN(index)) return; - this.startEditingWord(index); - return; - } - - // Don't select if clicking on eye toggle - if (target.closest('.word-item-eye-toggle')) { - return; - } - - // Don't select if clicking on color inputs or edit input - if (target.tagName === 'INPUT') { - if ((target as HTMLInputElement).type === 'color') { - event.stopPropagation(); - return; - } - if (target.classList.contains('word-edit-input')) { - event.stopPropagation(); - return; - } - } - - // Don't select if clicking inside word-actions area (except on the word item itself) - if (target.closest('.word-actions') && !target.classList.contains('word-item')) { - return; - } - - // Handle word item selection - const wordItem = target.closest('.word-item') as HTMLElement | null; - if (!wordItem) return; - - const index = Number(wordItem.dataset.index); - if (Number.isNaN(index)) return; - - const mouseEvent = event as MouseEvent; - // Ctrl/Cmd + click for multi-select - if (mouseEvent.ctrlKey || mouseEvent.metaKey) { - if (this.selectedWords.has(index)) { - this.selectedWords.delete(index); - } else { - this.selectedWords.add(index); - } - this.renderWords(); - return; - } - - // Regular click - clear all and select only this one - this.selectedWords.clear(); - this.selectedWords.add(index); - this.renderWords(); - } - - private handleWordListChange(event: Event): void { - const target = event.target as HTMLInputElement; - const list = this.lists[this.currentListIndex]; - if (!list) return; - - // Handle eye toggle (active/disabled) - if (target.classList.contains('word-item-eye-input')) { - const wordItem = target.closest('.word-item') as HTMLElement; - if (wordItem) { - const index = Number(wordItem.dataset.index); - if (!Number.isNaN(index)) { - const word = list.words[index]; - if (word) { - word.active = target.checked; - this.save(); - } - } - } - return; - } - - const editIndex = Number(target.dataset.bgEdit ?? target.dataset.fgEdit ?? -1); - if (Number.isNaN(editIndex) || editIndex < 0) return; - - const word = list.words[editIndex]; - if (!word) return; - - if (target.dataset.bgEdit != null) word.background = target.value; - if (target.dataset.fgEdit != null) word.foreground = target.value; - - this.save(); - } - - private startEditingWord(index: number): void { - const wordItem = document.querySelector(`.word-item[data-index="${index}"]`); - if (!wordItem) return; - - const textSpan = wordItem.querySelector('.word-text') as HTMLElement; - const input = wordItem.querySelector('.word-edit-input') as HTMLInputElement; - if (!textSpan || !input) return; - - textSpan.classList.add('editing'); - input.classList.add('active'); - input.focus(); - input.select(); - } - - private handleWordListBlur(event: Event): void { - const target = event.target as HTMLInputElement; - if (!target.classList.contains('word-edit-input')) return; - - const list = this.lists[this.currentListIndex]; - if (!list) return; - - const index = Number(target.dataset.wordEdit ?? -1); - if (Number.isNaN(index) || index < 0) return; - - const word = list.words[index]; - if (!word) return; - - const newValue = target.value.trim(); - if (newValue && newValue !== word.wordStr) { - word.wordStr = newValue; - this.save(); - } else { - this.renderWords(); - } - } - - private handleWordListKeydown(event: KeyboardEvent): void { - const target = event.target as HTMLInputElement; - if (!target.classList.contains('word-edit-input')) return; - - if (event.key === 'Enter') { - event.preventDefault(); - target.blur(); - } else if (event.key === 'Escape') { - event.preventDefault(); - this.renderWords(); - } - } - - private getSelectedListIndices(): number[] { - return Array.from(this.selectedLists).filter(index => this.lists[index]); - } - - /** Current list plus any Ctrl+clicked lists (effective selection for merge/duplicate/delete). */ - private getEffectiveSelectedListIndices(): number[] { - const indices = new Set(this.getSelectedListIndices()); - indices.add(this.currentListIndex); - return Array.from(indices).filter(index => this.lists[index]); - } - - private getFilteredWordEntries(list: HighlightList): Array<{ word: HighlightWord; index: number }> { - const query = this.wordSearchQuery.trim().toLowerCase(); - const entries = list.words.map((word, index) => ({ word, index })); - if (!query) return entries; - return entries.filter(entry => entry.word.wordStr.toLowerCase().includes(query)); - } - - private render(): void { - this.renderLists(); - this.renderSelectedList(); - this.renderWords(); - } - - private renderLists(): void { - const container = document.getElementById('listsContainer'); - if (!container) return; - - container.innerHTML = ''; - - this.lists.forEach((list, index) => { - const total = list.words.length; - const activeCount = list.words.filter(word => word.active).length; - - const item = document.createElement('div'); - item.className = 'list-item'; - if (index === this.currentListIndex) item.classList.add('active'); - if (this.selectedLists.has(index)) item.classList.add('selected'); - item.draggable = true; - item.dataset.index = index.toString(); - - const wordsLabel = chrome.i18n.getMessage('words_label') || 'words'; - const activeLabel = chrome.i18n.getMessage('active_label') || 'active'; - const badgeText = list.active - ? (chrome.i18n.getMessage('list_active_badge') || 'Active') - : (chrome.i18n.getMessage('list_paused_badge') || 'Paused'); - const toggleBadgeTitle = chrome.i18n.getMessage('toggle_active') || 'Toggle active'; - - item.innerHTML = ` -
-
${DOMUtils.escapeHtml(list.name)}
-
${total} ${wordsLabel} • ${activeCount} ${activeLabel}
-
-
${badgeText}
- `; - container.appendChild(item); - }); - } - - private renderSelectedList(): void { - const list = this.lists[this.currentListIndex]; - if (!list) return; - - const selectedListName = document.getElementById('selectedListName'); - if (selectedListName) { - selectedListName.textContent = list.name; - } - - (document.getElementById('listName') as HTMLInputElement).value = list.name; - (document.getElementById('listBg') as HTMLInputElement).value = list.background; - (document.getElementById('listFg') as HTMLInputElement).value = list.foreground; - - const stats = document.getElementById('listStats'); - if (stats) { - const activeCount = list.words.filter(word => word.active).length; - const inactiveCount = list.words.length - activeCount; - const wordsLabel = chrome.i18n.getMessage('words_label') || 'words'; - const activeLabel = chrome.i18n.getMessage('active_label') || 'active'; - const inactiveLabel = chrome.i18n.getMessage('inactive_label') || 'inactive'; - stats.textContent = `${list.words.length} ${wordsLabel} • ${activeCount} ${activeLabel} • ${inactiveCount} ${inactiveLabel}`; - } - - // Collapse settings panel when switching lists - const panel = document.getElementById('listSettingsPanel'); - if (panel) { - panel.classList.remove('expanded'); - } - - this.renderTargetListOptions(); - } - - private renderTargetListOptions(): void { - const select = document.getElementById('targetListSelect') as HTMLSelectElement; - if (!select) return; - - const options = this.lists - .map((list, index) => ({ list, index })) - .filter(entry => entry.index !== this.currentListIndex) - .map(entry => ``); - - select.innerHTML = options.join(''); - select.disabled = options.length === 0; - } - -private renderWords(): void { - const list = this.lists[this.currentListIndex]; - const wordList = document.getElementById('wordList'); - if (!list || !wordList) return; - - const entries = this.getFilteredWordEntries(list); - this.totalWords = entries.length; - - if (entries.length === 0) { - const emptyMessage = chrome.i18n.getMessage('no_words_in_list') || 'No words in this list.'; - wordList.innerHTML = `
${emptyMessage}
`; - this.renderPaginationControls(); - return; - } - - const startIndex = (this.currentPage - 1) * this.pageSize; - const endIndex = Math.min(startIndex + this.pageSize, this.totalWords); - const paginatedEntries = entries.slice(startIndex, endIndex); - - const editWordTitle = chrome.i18n.getMessage('edit_word') || 'Edit word'; - const bgColorTitle = chrome.i18n.getMessage('background_color_title') || 'Background color'; - const fgColorTitle = chrome.i18n.getMessage('text_color_title') || 'Text color'; - const toggleActiveTitle = chrome.i18n.getMessage('toggle_active') || 'Toggle active'; - - wordList.innerHTML = paginatedEntries.map(entry => { - const word = entry.word; - const index = entry.index; - const isSelected = this.selectedWords.has(index); - return ` -
- ${DOMUtils.escapeHtml(word.wordStr)} - -
- - - - -
-
- `; - }).join(''); - - this.renderPaginationControls(); - } - - private renderPaginationControls(): void { - const paginationContainer = document.getElementById('paginationControls'); - if (!paginationContainer) return; - - const totalPages = Math.ceil(this.totalWords / this.pageSize); - - if (totalPages <= 1) { - paginationContainer.style.display = 'none'; - return; - } - - const startItem = (this.currentPage - 1) * this.pageSize + 1; - const endItem = Math.min(this.currentPage * this.pageSize, this.totalWords); - - const showingText = chrome.i18n.getMessage('showing_items') - ?.replace('{start}', String(startItem)) - .replace('{end}', String(endItem)) - .replace('{total}', String(this.totalWords)) - || `Showing ${startItem}-${endItem} of ${this.totalWords} words`; - - const pageInfoText = chrome.i18n.getMessage('page_info') - ?.replace('{current}', String(this.currentPage)) - .replace('{total}', String(totalPages)) - || `Page ${this.currentPage} of ${totalPages}`; - - const itemsPerPageLabel = chrome.i18n.getMessage('items_per_page') || 'Items per page:'; - const firstPageTitle = chrome.i18n.getMessage('first_page') || 'First page'; - const prevPageTitle = chrome.i18n.getMessage('previous_page') || 'Previous page'; - const nextPageTitle = chrome.i18n.getMessage('next_page') || 'Next page'; - const lastPageTitle = chrome.i18n.getMessage('last_page') || 'Last page'; - - paginationContainer.style.display = 'flex'; - paginationContainer.innerHTML = ` -
- ${showingText} -
-
- - -
- ${pageInfoText} -
- - -
-
- - -
- `; - - this.setupPaginationEventListeners(); - } - - private setupPaginationEventListeners(): void { - document.getElementById('firstPageBtn')?.addEventListener('click', () => { - this.goToPage(1); - }); - - document.getElementById('prevPageBtn')?.addEventListener('click', () => { - this.goToPage(this.currentPage - 1); - }); - - document.getElementById('nextPageBtn')?.addEventListener('click', () => { - this.goToPage(this.currentPage + 1); - }); - - document.getElementById('lastPageBtn')?.addEventListener('click', () => { - const totalPages = Math.ceil(this.totalWords / this.pageSize); - this.goToPage(totalPages); - }); - - const pageSizeSelect = document.getElementById('pageSizeSelect') as HTMLSelectElement; - pageSizeSelect?.addEventListener('change', (e) => { - const newSize = Number((e.target as HTMLSelectElement).value); - if (!Number.isNaN(newSize) && newSize > 0) { - this.pageSize = newSize; - this.currentPage = 1; - this.renderWords(); - } - }); - } - - private goToPage(page: number): void { - const totalPages = Math.ceil(this.totalWords / this.pageSize); - if (page < 1 || page > totalPages) return; - - this.currentPage = page; - this.renderWords(); - } - -private async save(): Promise { - await StorageService.set({ - lists: this.lists - }); - this.render(); - MessageService.sendToAllTabs({ type: 'WORD_LIST_UPDATED' }); - } - - private setupTheme(): void { - const toggle = document.getElementById('themeToggle') as HTMLInputElement; - const body = document.body; - - const savedTheme = localStorage.getItem('theme'); - if (savedTheme === 'light') { - body.classList.remove('dark'); - body.classList.add('light'); - toggle.checked = false; - } else { - body.classList.add('dark'); - body.classList.remove('light'); - toggle.checked = true; - } - - toggle.addEventListener('change', () => { - if (toggle.checked) { - body.classList.add('dark'); - body.classList.remove('light'); - document.documentElement.classList.add('dark'); - document.documentElement.classList.remove('light'); - localStorage.setItem('theme', 'dark'); - } else { - body.classList.remove('dark'); - body.classList.add('light'); - document.documentElement.classList.remove('dark'); - document.documentElement.classList.add('light'); - localStorage.setItem('theme', 'light'); - } - }); - } -} diff --git a/src/list-manager/list-manager.ts b/src/list-manager/list-manager.ts deleted file mode 100644 index f204c9b..0000000 --- a/src/list-manager/list-manager.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { ListManagerController } from './ListManagerController.js'; - -function localizePage(): void { - const elements = document.querySelectorAll('[data-i18n]'); - elements.forEach(element => { - const message = (element as HTMLElement).dataset.i18n!; - const localizedText = chrome.i18n.getMessage(message); - if (localizedText) { - if ((element.tagName === 'INPUT' || element.tagName === 'TEXTAREA') && (element as HTMLInputElement).hasAttribute('placeholder')) { - (element as HTMLInputElement).placeholder = localizedText; - } else { - element.textContent = localizedText; - } - } - }); - - const titleElements = document.querySelectorAll('[data-i18n-title]'); - titleElements.forEach(element => { - const key = element.getAttribute('data-i18n-title'); - if (key) { - const translation = chrome.i18n.getMessage(key); - if (translation) { - element.setAttribute('title', translation); - } - } - }); -} - -const savedTheme = localStorage.getItem('theme'); -if (savedTheme === 'light') { - document.documentElement.classList.add('light'); -} else { - document.documentElement.classList.add('dark'); -} - -document.addEventListener('DOMContentLoaded', async () => { - localizePage(); - const controller = new ListManagerController(); - await controller.initialize(); -}); diff --git a/src/popup/PopupController.ts b/src/popup/PopupController.ts index 9ebed56..e77647e 100644 --- a/src/popup/PopupController.ts +++ b/src/popup/PopupController.ts @@ -1,4 +1,4 @@ -import { HighlightList, HighlightWord, ExportData, HighlightInfo } from '../types.js'; +import { HighlightList, HighlightWord, HighlightInfo } from '../types.js'; import { StorageService } from '../services/StorageService.js'; import { MessageService } from '../services/MessageService.js'; import { DOMUtils } from '../utils/DOMUtils.js'; @@ -213,11 +213,6 @@ export class PopupController { } }); - // Manage lists - document.getElementById('manageListsBtn')?.addEventListener('click', () => { - void this.openListManager(); - }); - // Color picker text inputs sync const listBg = document.getElementById('listBg') as HTMLInputElement; const listBgText = document.getElementById('listBgText') as HTMLInputElement; @@ -270,7 +265,6 @@ export class PopupController { }); this.setupWordListEvents(wordList); - this.setupWordSelection(); wordSearch.addEventListener('input', (e) => { this.wordSearchQuery = (e.target as HTMLInputElement).value; @@ -446,20 +440,53 @@ export class PopupController { input.select(); } + /** Effective selection for menu actions: multiple selected ? those indices : [wordIndex]. */ + private getEffectiveSelectionForMenu(wordIndex: number): number[] { + if (this.selectedCheckboxes.size > 1 && this.selectedCheckboxes.has(wordIndex)) { + return Array.from(this.selectedCheckboxes); + } + return [wordIndex]; + } + private openWordItemMenu(wordIndex: number, buttonEl: HTMLElement): void { const dropdown = document.getElementById('wordItemMenuDropdown'); if (!dropdown) return; this.closeWordItemMenu(); + const effectiveIndices = this.getEffectiveSelectionForMenu(wordIndex); + const isMultiple = effectiveIndices.length > 1; + const rect = buttonEl.getBoundingClientRect(); const padding = 8; dropdown.style.left = `${rect.left}px`; dropdown.style.top = `${rect.bottom + 4}px`; dropdown.style.right = ''; - const moveLabel = chrome.i18n.getMessage('move_to_list') || 'Move to list'; - const copyLabel = chrome.i18n.getMessage('copy_to_list') || 'Copy to list'; + const moveLabel = isMultiple + ? (chrome.i18n.getMessage('move_selected') || 'Move selected') + : (chrome.i18n.getMessage('move_to_list') || 'Move to list'); + const copyLabel = isMultiple + ? (chrome.i18n.getMessage('copy_selected') || 'Copy selected') + : (chrome.i18n.getMessage('copy_to_list') || 'Copy to list'); + const enableSelectedLabel = chrome.i18n.getMessage('enable_selected') || 'Enable selected'; + const disableSelectedLabel = chrome.i18n.getMessage('disable_selected') || 'Disable selected'; + const deleteLabel = isMultiple + ? (chrome.i18n.getMessage('delete_selected') || 'Delete selected') + : (chrome.i18n.getMessage('delete_selected') || 'Delete'); + + const enableDisableItems = isMultiple + ? ` + + + ` + : ''; dropdown.innerHTML = ` + ${enableDisableItems} + `; dropdown.querySelectorAll('.word-item-menu-item').forEach(item => { @@ -477,14 +509,33 @@ export class PopupController { e.stopPropagation(); const action = (item as HTMLElement).dataset.action; if (action === 'move') { - this.showWordMenuListPicker(wordIndex, false); + this.showWordMenuListPickerForIndices(effectiveIndices, false); } else if (action === 'copy') { - this.showWordMenuListPicker(wordIndex, true); + this.showWordMenuListPickerForIndices(effectiveIndices, true); + } else if (action === 'enable') { + this.setSelectedWordsActive(effectiveIndices, true); + this.closeWordItemMenu(); + this.save(); + this.renderWords(); + } else if (action === 'disable') { + this.setSelectedWordsActive(effectiveIndices, false); + this.closeWordItemMenu(); + this.save(); + this.renderWords(); + } else if (action === 'delete') { + if (confirm(chrome.i18n.getMessage('confirm_delete_words') || 'Delete selected words?')) { + this.deleteWordsByIndices(effectiveIndices); + this.selectedCheckboxes.clear(); + this.closeWordItemMenu(); + this.save(); + this.renderWords(); + } } }); }); this.wordMenuOpenForIndex = wordIndex; + this.wordMenuCopyOnly = false; dropdown.classList.add('open'); dropdown.setAttribute('aria-hidden', 'false'); @@ -520,12 +571,11 @@ export class PopupController { setTimeout(() => document.addEventListener('click', closeHandler), 0); } - private showWordMenuListPicker(wordIndex: number, copyOnly: boolean): void { + private showWordMenuListPickerForIndices(indices: number[], copyOnly: boolean): void { const dropdown = document.getElementById('wordItemMenuDropdown'); if (!dropdown || this.wordMenuOpenForIndex === null) return; this.wordMenuCopyOnly = copyOnly; - const currentList = this.lists[this.currentListIndex]; const otherLists = this.lists .map((list, index) => ({ list, index })) .filter(({ index }) => index !== this.currentListIndex); @@ -553,9 +603,9 @@ export class PopupController { const targetIndex = Number((item as HTMLElement).dataset.targetIndex); if (Number.isNaN(targetIndex)) return; if (this.wordMenuCopyOnly) { - this.copyWordToOtherList(wordIndex, targetIndex); + this.copyWordsToOtherList(indices, targetIndex); } else { - this.moveWordToOtherList(wordIndex, targetIndex); + this.moveWordsToOtherList(indices, targetIndex); } this.closeWordItemMenu(); this.save(); @@ -565,6 +615,42 @@ export class PopupController { }); } + private setSelectedWordsActive(indices: number[], active: boolean): void { + const list = this.lists[this.currentListIndex]; + if (!list) return; + indices.forEach(index => { + const word = list.words[index]; + if (word) word.active = active; + }); + } + + private deleteWordsByIndices(indices: number[]): void { + const list = this.lists[this.currentListIndex]; + if (!list) return; + const toDelete = new Set(indices); + this.lists[this.currentListIndex].words = list.words.filter((_, i) => !toDelete.has(i)); + } + + private moveWordsToOtherList(indices: number[], targetListIndex: number): void { + const list = this.lists[this.currentListIndex]; + const targetList = this.lists[targetListIndex]; + if (!list || !targetList) return; + const sorted = [...indices].sort((a, b) => b - a); + const wordsToMove = sorted.map(i => list.words[i]).filter(Boolean); + sorted.forEach(i => list.words.splice(i, 1)); + targetList.words.push(...wordsToMove); + } + + private copyWordsToOtherList(indices: number[], targetListIndex: number): void { + const list = this.lists[this.currentListIndex]; + const targetList = this.lists[targetListIndex]; + if (!list || !targetList) return; + indices.forEach(index => { + const word = list.words[index]; + if (word) targetList.words.push({ ...word }); + }); + } + private closeWordItemMenu(): void { const dropdown = document.getElementById('wordItemMenuDropdown'); if (dropdown) { @@ -578,67 +664,6 @@ export class PopupController { } } - private moveWordToOtherList(wordIndex: number, targetListIndex: number): void { - const list = this.lists[this.currentListIndex]; - const targetList = this.lists[targetListIndex]; - const word = list.words[wordIndex]; - if (!word || !targetList) return; - targetList.words.push({ ...word }); - list.words.splice(wordIndex, 1); - } - - private copyWordToOtherList(wordIndex: number, targetListIndex: number): void { - const list = this.lists[this.currentListIndex]; - const targetList = this.lists[targetListIndex]; - const word = list.words[wordIndex]; - if (!word || !targetList) return; - targetList.words.push({ ...word }); - } - - private setupWordSelection(): void { - document.getElementById('selectAllBtn')?.addEventListener('click', () => { - const list = this.lists[this.currentListIndex]; - list.words.forEach((_, index) => { - this.selectedCheckboxes.add(index); - }); - this.renderWords(); - }); - - document.getElementById('deselectAllBtn')?.addEventListener('click', () => { - this.selectedCheckboxes.clear(); - this.renderWords(); - }); - - document.getElementById('deleteSelectedBtn')?.addEventListener('click', () => { - if (confirm(chrome.i18n.getMessage('confirm_delete_words') || 'Delete selected words?')) { - const list = this.lists[this.currentListIndex]; - const toDelete = Array.from(this.selectedCheckboxes); - this.lists[this.currentListIndex].words = list.words.filter((_, i) => !toDelete.includes(i)); - this.selectedCheckboxes.clear(); - this.save(); - this.renderWords(); - } - }); - - document.getElementById('enableSelectedBtn')?.addEventListener('click', () => { - const list = this.lists[this.currentListIndex]; - this.selectedCheckboxes.forEach(index => { - list.words[index].active = true; - }); - this.save(); - this.renderWords(); - }); - - document.getElementById('disableSelectedBtn')?.addEventListener('click', () => { - const list = this.lists[this.currentListIndex]; - this.selectedCheckboxes.forEach(index => { - list.words[index].active = false; - }); - this.save(); - this.renderWords(); - }); - } - private setupSettings(): void { const globalToggle = document.getElementById('globalHighlightToggle') as HTMLInputElement; const matchCase = document.getElementById('matchCase') as HTMLInputElement; @@ -824,27 +849,26 @@ export class PopupController { } private setupImportExport(): void { - const importInput = document.getElementById('importInput') as HTMLInputElement; + const importListInput = document.getElementById('importListInput') as HTMLInputElement; - document.getElementById('exportBtn')?.addEventListener('click', () => { - const exportData: ExportData = { - lists: this.lists, - exceptionsList: this.exceptionsList - }; - const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }); + document.getElementById('exportListBtn')?.addEventListener('click', () => { + const list = this.lists[this.currentListIndex]; + if (!list) return; + const blob = new Blob([JSON.stringify(list, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - a.download = 'highlight-lists.json'; + const safeName = (list.name || 'list').replace(/[^a-zA-Z0-9-_]/g, '-'); + a.download = `${safeName}.json`; a.click(); URL.revokeObjectURL(url); }); - document.getElementById('importBtn')?.addEventListener('click', () => { - importInput.click(); + document.getElementById('importListBtn')?.addEventListener('click', () => { + importListInput?.click(); }); - importInput.addEventListener('change', (e) => { + importListInput?.addEventListener('change', (e) => { const file = (e.target as HTMLInputElement).files?.[0]; if (!file) return; @@ -852,30 +876,52 @@ export class PopupController { reader.onload = (e) => { try { const data = JSON.parse(e.target?.result as string); + const toAdd: HighlightList[] = []; if (Array.isArray(data)) { - this.lists = data; + data.forEach((item: unknown) => { + if (this.isValidList(item)) toAdd.push(item as HighlightList); + }); } else if (data && typeof data === 'object') { if (Array.isArray(data.lists)) { - this.lists = data.lists; - } - if (Array.isArray(data.exceptionsList)) { - this.exceptionsList = data.exceptionsList; + data.lists.forEach((item: unknown) => { + if (this.isValidList(item)) toAdd.push(item as HighlightList); + }); + } else if (this.isValidList(data)) { + toAdd.push(data as HighlightList); } } - this.currentListIndex = 0; - this.updateExceptionButton(); - this.renderExceptions(); + if (toAdd.length === 0) { + alert(chrome.i18n.getMessage('invalid_import_format') || 'Invalid list format. Please select a valid list file.'); + return; + } + const baseId = Date.now(); + toAdd.forEach((l, i) => { + this.lists.push({ ...l, id: baseId + i }); + }); this.save(); + this.renderLists(); } catch (err) { alert(chrome.i18n.getMessage('invalid_json_error') + ': ' + (err as Error).message); } }; reader.readAsText(file); + importListInput.value = ''; }); } + private isValidList(obj: unknown): obj is HighlightList { + if (!obj || typeof obj !== 'object') return false; + const o = obj as Record; + return ( + typeof o.name === 'string' && + Array.isArray(o.words) && + (typeof o.background === 'string' || typeof o.background === 'undefined') && + (typeof o.foreground === 'string' || typeof o.foreground === 'undefined') + ); + } + private setupTheme(): void { const themeToggle = document.getElementById('themeToggle') as HTMLInputElement; @@ -941,10 +987,6 @@ export class PopupController { MessageService.sendToAllTabs({ type: 'WORD_LIST_UPDATED' }); } - private async openListManager(): Promise { - await chrome.tabs.create({ url: chrome.runtime.getURL('list-manager/list-manager.html') }); - } - private setupStorageSync(): void { chrome.storage.onChanged.addListener((changes, areaName) => { if (areaName !== 'local') return;