// ==UserScript==
// @name Komica 收藏貼文功能
// @namespace https://komica.org/
// @version 4.1
// @description 在 Komica 每篇貼文添加收藏功能,提供懸浮視窗管理收藏,支持自定義位置、大小及配色切換,並新增收藏圖片縮圖功能。
// @author Yun
// @license GNU GPLv3
// @icon https://i.ibb.co/bscXhHh/icon.png
// @match https://gita.komica1.org/*
// @grant GM_setValue
// @grant GM_getValue
// ==/UserScript==
(function() {
'use strict';
// 常量定義
const CONSTANTS = {
STORAGE_KEYS: {
FAVORITES: 'komicaFavorites',
POSITION: 'favoritesWindowPosition',
SIZE: 'favoritesWindowSize',
THEME: 'favoritesWindowTheme',
CATEGORIES: 'komicaCategories'
},
DEFAULT_VALUES: {
POSITION: { top: '10px', left: '10px' },
SIZE: { width: '300px', height: '400px' },
THEME: 'original'
},
THEMES: {
original: { background: '#F0E0D6', header: '#EA8', text: '#800000', button: '#EA8', border: '#B89080' },
blackWhite: { background: '#FFFFFF', header: '#BBBBBB', text: '#000000', button: '#BBBBBB', border: '#999999' },
blue: { background: '#DDEEFF', header: '#6699CC', text: '#003366', button: '#6699CC', border: '#4477AA' }
},
HOTKEYS: {
TOGGLE_WINDOW: 'Alt+F',
QUICK_SAVE: 'Alt+S'
}
};
// 工具函數
const utils = {
throttle: (func, limit) => {
let inThrottle;
return function(...args) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
},
showToast: (message, type = 'info') => {
const toast = document.createElement('div');
toast.textContent = message;
toast.style.cssText = `
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
padding: 10px 20px;
border-radius: 5px;
color: white;
z-index: 10002;
opacity: 0;
transition: opacity 0.3s;
`;
switch(type) {
case 'success':
toast.style.backgroundColor = '#4CAF50';
break;
case 'error':
toast.style.backgroundColor = '#f44336';
break;
default:
toast.style.backgroundColor = '#2196F3';
}
document.body.appendChild(toast);
setTimeout(() => toast.style.opacity = '1', 10);
setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 300);
}, 2000);
},
exportData: () => {
const data = {
favorites: favorites,
categories: categories
};
const blob = new Blob([JSON.stringify(data, null, 2)], {type: 'application/json'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `komica_favorites_${new Date().toISOString().slice(0,10)}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
utils.showToast('匯出成功!', 'success');
},
importData: (file) => {
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = JSON.parse(e.target.result);
if (data.favorites && Array.isArray(data.favorites)) {
favorites = data.favorites;
localStorage.setItem(CONSTANTS.STORAGE_KEYS.FAVORITES, JSON.stringify(favorites));
if (data.categories) {
categories = data.categories;
localStorage.setItem(CONSTANTS.STORAGE_KEYS.CATEGORIES, JSON.stringify(categories));
}
updateFavoritesWindow();
utils.showToast('匯入成功!', 'success');
} else {
utils.showToast('無效的檔案格式!', 'error');
}
} catch (error) {
utils.showToast('匯入失敗!', 'error');
console.error('Import error:', error);
}
};
reader.readAsText(file);
},
// 更新分類列表
updateCategories: () => {
// 獲取所有已使用的分類
const usedCategories = new Set(favorites.map(fav => fav.category));
// 更新分類列表,只保留已使用的分類和"未分類"
categories = Array.from(usedCategories);
if (!categories.includes('未分類')) {
categories.push('未分類');
}
// 儲存更新後的分類列表
localStorage.setItem(CONSTANTS.STORAGE_KEYS.CATEGORIES, JSON.stringify(categories));
// 更新分類選單
const categorySelect = document.querySelector('#favoritesWindow select');
if (categorySelect) {
const currentValue = categorySelect.value;
categorySelect.innerHTML = `
<option value="全部">全部</option>
${categories.map(cat => `<option value="${cat}">${cat}</option>`).join('')}
`;
categorySelect.value = currentValue;
}
}
};
// 狀態管理
let favorites = JSON.parse(localStorage.getItem(CONSTANTS.STORAGE_KEYS.FAVORITES)) || [];
let categories = JSON.parse(localStorage.getItem(CONSTANTS.STORAGE_KEYS.CATEGORIES)) || ['未分類'];
let position = JSON.parse(localStorage.getItem(CONSTANTS.STORAGE_KEYS.POSITION)) || CONSTANTS.DEFAULT_VALUES.POSITION;
let size = JSON.parse(localStorage.getItem(CONSTANTS.STORAGE_KEYS.SIZE)) || CONSTANTS.DEFAULT_VALUES.SIZE;
let theme = JSON.parse(localStorage.getItem(CONSTANTS.STORAGE_KEYS.THEME)) || CONSTANTS.DEFAULT_VALUES.THEME;
let currentCategory = '全部';
let currentSort = 'time-desc';
let searchTerm = '';
// 更新收藏按鈕顏色
function updateCollectButtonsColor() {
document.querySelectorAll('.collect-btn').forEach(btn => {
btn.style.color = CONSTANTS.THEMES[theme].text;
});
}
// 應用主題
function applyTheme(favoritesWindow, toggleBtn) {
const currentTheme = CONSTANTS.THEMES[theme];
favoritesWindow.style.backgroundColor = currentTheme.background;
favoritesWindow.querySelector('.window-header').style.backgroundColor = currentTheme.header;
favoritesWindow.querySelector('.window-header').style.color = currentTheme.text;
if (toggleBtn) {
toggleBtn.style.backgroundColor = currentTheme.button;
toggleBtn.style.color = currentTheme.text;
}
// 更新所有文字顏色
document.querySelectorAll('#favoritesWindow a, #favoritesWindow p, #favoritesWindow select')
.forEach(el => el.style.color = currentTheme.text);
// 新增:更新編輯和刪除按鈕顏色
document.querySelectorAll('#favoritesWindow .favorites-content span[title="編輯分類"], #favoritesWindow .favorites-content span[title="移除收藏"]')
.forEach(btn => btn.style.color = currentTheme.text);
// 新增:更新分類標籤顏色
document.querySelectorAll('#favoritesWindow .favorites-content span:not([title])')
.forEach(tag => {
tag.style.backgroundColor = `${currentTheme.header}40`;
tag.style.color = currentTheme.text;
});
document.querySelectorAll('#favoritesWindow input, #favoritesWindow select')
.forEach(el => {
el.style.border = `1px solid ${currentTheme.border}`;
el.style.backgroundColor = currentTheme.background;
el.style.color = currentTheme.text;
});
// 更新分隔線顏色
document.querySelectorAll('#favoritesWindow .favorites-content > div')
.forEach(item => {
item.style.borderBottom = `1px solid ${currentTheme.border}`;
});
document.querySelector('#favoritesWindow .favorites-toolbar')?.style
.setProperty('border-bottom-color', currentTheme.border);
updateCollectButtonsColor();
}
// 建立工具列
function createToolbar(favoritesWindow) {
const toolbar = document.createElement('div');
toolbar.className = 'favorites-toolbar';
toolbar.style.cssText = `
padding: 5px;
display: flex;
gap: 10px;
align-items: center;
border-bottom: 1px solid ${CONSTANTS.THEMES[theme].border};
`;
// 搜尋框
const searchInput = document.createElement('input');
searchInput.type = 'text';
searchInput.placeholder = '搜尋...';
searchInput.style.cssText = `
padding: 3px;
border: 1px solid ${CONSTANTS.THEMES[theme].border};
border-radius: 3px;
flex: 1;
background-color: ${CONSTANTS.THEMES[theme].background};
color: ${CONSTANTS.THEMES[theme].text};
`;
searchInput.style.setProperty('::placeholder', CONSTANTS.THEMES[theme].text);
searchInput.addEventListener('input', (e) => {
searchTerm = e.target.value.toLowerCase();
updateFavoritesWindow();
});
// 分類選擇
const categorySelect = document.createElement('select');
categorySelect.style.cssText = `
padding: 3px;
border: 1px solid ${CONSTANTS.THEMES[theme].border};
border-radius: 3px;
background-color: ${CONSTANTS.THEMES[theme].background};
color: ${CONSTANTS.THEMES[theme].text};
`;
categorySelect.innerHTML = `
<option value="全部">全部</option>
${categories.map(cat => `<option value="${cat}">${cat}</option>`).join('')}
`;
categorySelect.value = currentCategory;
categorySelect.addEventListener('change', (e) => {
currentCategory = e.target.value;
updateFavoritesWindow();
});
// 排序選擇
const sortSelect = document.createElement('select');
sortSelect.style.cssText = `
padding: 3px;
border: 1px solid ${CONSTANTS.THEMES[theme].border};
border-radius: 3px;
background-color: ${CONSTANTS.THEMES[theme].background};
color: ${CONSTANTS.THEMES[theme].text};
`;
sortSelect.innerHTML = `
<option value="time-desc">時間 ↓</option>
<option value="time-asc">時間 ↑</option>
<option value="id-desc">編號 ↓</option>
<option value="id-asc">編號 ↑</option>
`;
sortSelect.value = currentSort;
sortSelect.addEventListener('change', (e) => {
currentSort = e.target.value;
updateFavoritesWindow();
});
toolbar.appendChild(searchInput);
toolbar.appendChild(categorySelect);
toolbar.appendChild(sortSelect);
return toolbar;
}
// 添加收藏按鈕
function addCollectButtons() {
const posts = document.querySelectorAll('.post:not(.has-collect-btn)');
posts.forEach(post => {
post.classList.add('has-collect-btn');
const postId = post.dataset.no;
const threadId = post.closest('.thread')?.dataset.no || postId;
const thumbnail = post.querySelector('img')?.src || null;
if (!postId) return;
const collectBtn = document.createElement('span');
collectBtn.textContent = favorites.some(fav => fav.id === postId) ? '★' : '☆';
collectBtn.className = 'collect-btn text-button';
collectBtn.style.cssText = `
margin-left: 10px;
cursor: pointer;
color: ${CONSTANTS.THEMES[theme].text};
`;
collectBtn.addEventListener('click', () => {
const existingFavorite = favorites.find(fav => fav.id === postId);
if (existingFavorite) {
favorites = favorites.filter(fav => fav.id !== postId);
collectBtn.textContent = '☆';
utils.showToast('已移除收藏', 'info');
} else {
const postContent = post.querySelector('.quote')?.textContent || '無內文';
favorites.push({
id: postId,
url: `https://gita.komica1.org/00b/pixmicat.php?res=${threadId}#r${postId}`,
content: postContent,
thumbnail,
category: '未分類',
timestamp: Date.now()
});
collectBtn.textContent = '★';
utils.showToast('已加入收藏', 'success');
}
localStorage.setItem(CONSTANTS.STORAGE_KEYS.FAVORITES, JSON.stringify(favorites));
utils.updateCategories(); // 更新分類列表
updateFavoritesWindow();
});
const postHead = post.querySelector('.post-head');
if (postHead) postHead.appendChild(collectBtn);
});
}
// 建立收藏視窗
function createFavoritesWindow() {
const favoritesWindow = document.createElement('div');
favoritesWindow.id = 'favoritesWindow';
favoritesWindow.style.cssText = `
position: fixed;
top: ${position.top};
left: ${position.left};
width: ${size.width};
height: ${size.height};
border: 1px solid #666;
box-shadow: 0 3px 10px rgba(0,0,0,0.75);
overflow: hidden;
display: none;
resize: both;
z-index: 10001;
flex-direction: column;
`;
// 視窗標題列
const header = document.createElement('div');
header.className = 'window-header';
header.style.cssText = `
padding: 5px;
cursor: move;
display: flex;
justify-content: space-between;
align-items: center;
`;
const title = document.createElement('span');
title.textContent = '已收藏的貼文';
title.style.fontWeight = 'bold';
const buttonContainer = document.createElement('div');
buttonContainer.style.display = 'flex';
buttonContainer.style.gap = '10px';
// 匯出按鈕
const exportBtn = document.createElement('span');
exportBtn.textContent = '↓';
exportBtn.title = '匯出收藏';
exportBtn.style.cursor = 'pointer';
exportBtn.addEventListener('click', utils.exportData);
// 匯入按鈕
const importBtn = document.createElement('span');
importBtn.textContent = '↑';
importBtn.title = '匯入收藏';
importBtn.style.cursor = 'pointer';
const importInput = document.createElement('input');
importInput.type = 'file';
importInput.accept = '.json';
importInput.style.display = 'none';
importInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
utils.importData(e.target.files[0]);
e.target.value = ''; // 重置 input,允許重複匯入相同檔案
}
});
importBtn.addEventListener('click', () => importInput.click());
document.body.appendChild(importInput);
// 清空按鈕
const clearAllBtn = document.createElement('span');
clearAllBtn.textContent = '⌫';
clearAllBtn.style.cursor = 'pointer';
clearAllBtn.title = '清空收藏';
clearAllBtn.addEventListener('click', () => {
if (confirm('確定要清空所有收藏嗎?')) {
favorites = [];
localStorage.setItem(CONSTANTS.STORAGE_KEYS.FAVORITES, JSON.stringify(favorites));
utils.updateCategories(); // 更新分類列表
updateFavoritesWindow();
utils.showToast('已清空所有收藏', 'info');
}
});
// 主題切換按鈕
const changeThemeBtn = document.createElement('span');
changeThemeBtn.textContent = '↹';
changeThemeBtn.style.cursor = 'pointer';
changeThemeBtn.title = '切換配色';
changeThemeBtn.addEventListener('click', () => {
const themeKeys = Object.keys(CONSTANTS.THEMES);
const currentIndex = themeKeys.indexOf(theme);
theme = themeKeys[(currentIndex + 1) % themeKeys.length];
localStorage.setItem(CONSTANTS.STORAGE_KEYS.THEME, JSON.stringify(theme));
applyTheme(favoritesWindow, toggleBtn);
updateFavoritesWindow(); // 重新渲染收藏列表以更新所有元素顏色
utils.showToast(`已切換至${theme}主題`, 'info');
});
// 關閉按鈕
const closeBtn = document.createElement('span');
closeBtn.textContent = '✕';
closeBtn.style.cursor = 'pointer';
closeBtn.title = '關閉視窗';
closeBtn.addEventListener('click', () => {
favoritesWindow.style.display = 'none';
});
// 添加所有按鈕到容器
[exportBtn, importBtn, clearAllBtn, changeThemeBtn, closeBtn].forEach(btn => {
buttonContainer.appendChild(btn);
});
header.appendChild(title);
header.appendChild(buttonContainer);
favoritesWindow.appendChild(header);
// 添加工具列
const toolbar = createToolbar(favoritesWindow);
favoritesWindow.appendChild(toolbar);
// 內容區域
const content = document.createElement('div');
content.className = 'favorites-content';
content.style.cssText = `
flex: 1;
overflow-y: auto;
padding: 10px;
`;
favoritesWindow.appendChild(content);
// 拖曳功能
let isDragging = false;
let offsetX, offsetY;
const startDragging = (e) => {
if (e.target !== header) return;
isDragging = true;
offsetX = e.clientX - favoritesWindow.offsetLeft;
offsetY = e.clientY - favoritesWindow.offsetTop;
header.style.cursor = 'grabbing';
};
const onDrag = utils.throttle((e) => {
if (!isDragging) return;
favoritesWindow.style.left = `${e.clientX - offsetX}px`;
favoritesWindow.style.top = `${e.clientY - offsetY}px`;
}, 16);
const stopDragging = () => {
if (!isDragging) return;
isDragging = false;
header.style.cursor = 'move';
position = {
top: favoritesWindow.style.top,
left: favoritesWindow.style.left
};
localStorage.setItem(CONSTANTS.STORAGE_KEYS.POSITION, JSON.stringify(position));
};
header.addEventListener('mousedown', startDragging);
document.addEventListener('mousemove', onDrag);
document.addEventListener('mouseup', stopDragging);
// 視窗大小調整
const onResize = utils.throttle(() => {
size = {
width: favoritesWindow.style.width,
height: favoritesWindow.style.height
};
localStorage.setItem(CONSTANTS.STORAGE_KEYS.SIZE, JSON.stringify(size));
}, 100);
favoritesWindow.addEventListener('mouseup', onResize);
// 切換按鈕
const toggleBtn = document.createElement('div');
toggleBtn.textContent = '★ 收藏';
toggleBtn.style.cssText = `
position: fixed;
bottom: 10px;
right: 10px;
padding: 5px 10px;
cursor: pointer;
z-index: 10000;
border-radius: 3px;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
`;
toggleBtn.addEventListener('click', () => {
favoritesWindow.style.display = favoritesWindow.style.display === 'none' ? 'flex' : 'none';
});
// 快捷鍵支援
document.addEventListener('keydown', (e) => {
// Alt + F:切換收藏視窗
if (e.altKey && e.key.toLowerCase() === 'f') {
e.preventDefault();
toggleBtn.click();
}
// Alt + S:快速收藏當前貼文
if (e.altKey && e.key.toLowerCase() === 's') {
e.preventDefault();
const activePost = document.activeElement.closest('.post');
if (activePost) {
const collectBtn = activePost.querySelector('.collect-btn');
if (collectBtn) collectBtn.click();
}
}
});
document.body.appendChild(favoritesWindow);
document.body.appendChild(toggleBtn);
applyTheme(favoritesWindow, toggleBtn);
updateFavoritesWindow();
return favoritesWindow;
}
function updateFavoritesWindow() {
const content = document.querySelector('#favoritesWindow .favorites-content');
if (!content) return;
content.innerHTML = '';
let filteredFavorites = [...favorites];
// 套用分類篩選
if (currentCategory !== '全部') {
filteredFavorites = filteredFavorites.filter(fav => fav.category === currentCategory);
}
// 套用搜尋篩選
if (searchTerm) {
filteredFavorites = filteredFavorites.filter(fav =>
fav.content.toLowerCase().includes(searchTerm) ||
fav.id.includes(searchTerm)
);
}
// 套用排序
filteredFavorites.sort((a, b) => {
switch(currentSort) {
case 'time-desc':
return (b.timestamp || 0) - (a.timestamp || 0);
case 'time-asc':
return (a.timestamp || 0) - (b.timestamp || 0);
case 'id-desc':
return b.id.localeCompare(a.id);
case 'id-asc':
return a.id.localeCompare(b.id);
default:
return 0;
}
});
if (filteredFavorites.length === 0) {
const noFavorites = document.createElement('p');
noFavorites.textContent = searchTerm ? '沒有符合搜尋條件的收藏' : '尚無收藏';
noFavorites.style.textAlign = 'center';
noFavorites.style.color = CONSTANTS.THEMES[theme].text;
content.appendChild(noFavorites);
return;
}
// 使用 DocumentFragment 優化 DOM 操作
const fragment = document.createDocumentFragment();
filteredFavorites.forEach(({ id, url, content: favContent, thumbnail, category }) => {
const item = document.createElement('div');
item.style.cssText = `
display: flex;
align-items: center;
padding: 5px;
border-bottom: 1px solid ${CONSTANTS.THEMES[theme].border};
gap: 10px;
`;
if (thumbnail) {
const img = document.createElement('img');
img.src = thumbnail;
img.alt = '縮圖';
img.style.cssText = `
width: 50px;
height: 50px;
object-fit: cover;
border-radius: 3px;
`;
item.appendChild(img);
}
const contentWrapper = document.createElement('div');
contentWrapper.style.flex = '1';
const link = document.createElement('a');
link.href = url;
link.textContent = `${id}: ${favContent.substring(0, 30)}...`;
link.style.cssText = `
display: block;
text-decoration: none;
color: ${CONSTANTS.THEMES[theme].text};
margin-bottom: 5px;
`;
const categoryTag = document.createElement('span');
categoryTag.textContent = category;
categoryTag.style.cssText = `
font-size: 0.8em;
padding: 2px 5px;
background-color: ${CONSTANTS.THEMES[theme].header}40;
border-radius: 3px;
color: ${CONSTANTS.THEMES[theme].text};
`;
contentWrapper.appendChild(link);
contentWrapper.appendChild(categoryTag);
item.appendChild(contentWrapper);
// 操作按鈕容器
const actions = document.createElement('div');
actions.style.display = 'flex';
actions.style.gap = '5px';
// 編輯分類按鈕
// 編輯分類按鈕
const editCategoryBtn = document.createElement('span');
editCategoryBtn.textContent = '✎';
editCategoryBtn.title = '編輯分類';
editCategoryBtn.style.cssText = `
cursor: pointer;
color: ${CONSTANTS.THEMES[theme].text};
`;
editCategoryBtn.addEventListener('click', () => {
const newCategory = prompt('請輸入分類名稱:', category);
if (newCategory !== null && newCategory.trim() !== '') {
const fav = favorites.find(f => f.id === id);
if (fav) {
fav.category = newCategory.trim();
localStorage.setItem(CONSTANTS.STORAGE_KEYS.FAVORITES, JSON.stringify(favorites));
utils.updateCategories(); // 更新分類列表
updateFavoritesWindow();
utils.showToast('已更新分類', 'success');
}
}
});
// 刪除按鈕
const removeBtn = document.createElement('span');
removeBtn.textContent = '✕';
removeBtn.title = '移除收藏';
removeBtn.style.cssText = `
cursor: pointer;
color: ${CONSTANTS.THEMES[theme].text};
`;
removeBtn.addEventListener('click', () => {
favorites = favorites.filter(fav => fav.id !== id);
localStorage.setItem(CONSTANTS.STORAGE_KEYS.FAVORITES, JSON.stringify(favorites));
utils.updateCategories(); // 更新分類列表
updateFavoritesWindow();
utils.showToast('已移除收藏', 'info');
});
actions.appendChild(editCategoryBtn);
actions.appendChild(removeBtn);
item.appendChild(actions);
fragment.appendChild(item);
});
content.appendChild(fragment);
}
function init() {
addCollectButtons();
createFavoritesWindow();
}
// 監聽 DOM 變化以添加收藏按鈕
const observer = new MutationObserver(() => {
addCollectButtons();
});
observer.observe(document.body, { childList: true, subtree: true });
init();
})();