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
-
-
-
-
-
-
-
-
-
-
-
- Drag lists to reorder • Ctrl+Click for multi-select
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
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 = `
-
- ${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 `
-
- `;
- }).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 = `
-
-
-
-
-
-
- `;
-
- 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;