An elegant floating navigator for Arena (LMArena) chat. Features: quick navigation, favorites with batch operations, search, customizable keyboard shortcuts, dynamic density indicator with Dock-like fisheye effect, and more.
// ==UserScript==
// @name Arena Chat Navigator
// @name:en Arena Chat Navigator
// @name:zh-TW Arena 聊天導航器
// @namespace https://github.com/users/Marx-Einstein/projects/1
// @version 1.2.0
// @description An elegant floating navigator for Arena (LMArena) chat. Features: quick navigation, favorites with batch operations, search, customizable keyboard shortcuts, dynamic density indicator with Dock-like fisheye effect, and more.
// @description:en An elegant floating navigator for Arena (LMArena) chat. Features: quick navigation, favorites with batch operations, search, customizable keyboard shortcuts, dynamic density indicator with Dock-like fisheye effect, and more.
// @author Marx Einstein
// @match https://arena.ai/*
// @icon https://arena.ai/favicon.ico
// @grant GM_addStyle
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_registerMenuCommand
// @grant GM.addStyle
// @grant GM.setValue
// @grant GM.getValue
// @grant GM.deleteValue
// @grant GM.registerMenuCommand
// @license MIT
// @homepageURL https://github.com/users/Marx-Einstein/projects/1
// ==/UserScript==
(function () {
"use strict";
if (window.__LMARENA_CHAT_NAV_LOADED__) return;
window.__LMARENA_CHAT_NAV_LOADED__ = true;
const DEBUG = false;
const log = (...args) => { if (DEBUG) console.log('[LMArena Nav]', ...args); };
// ========================================
// Design Tokens (統一的設計系統)
// ========================================
const DESIGN_TOKENS = {
// 間距系統 (4px 基準)
space: {
xs: 4,
sm: 8,
md: 12,
lg: 16,
xl: 20,
xxl: 24,
},
// 圓角系統
radius: {
xs: 4,
sm: 6,
md: 10,
lg: 14,
full: 9999,
},
// 動畫時長
duration: {
fast: 100,
normal: 200,
slow: 300,
},
// 指示條脈衝設計
pulse: {
minHeight: 2,
maxHeight: 6,
standardHeight: 4,
minWidth: 8,
maxWidth: 32,
standardWidth: 16,
gap: {
standard: 6,
compressed: 2,
minimum: 1,
},
},
};
// ========================================
// Configuration
// ========================================
const CONFIG = {
CONTAINER_ID: "lm-nav-container",
INDICATOR_ID: "lm-nav-indicator",
WRAPPER_ID: "lm-nav-wrapper",
PANEL_ID: "lm-nav-panel",
FAB_ID: "lm-nav-fab",
JIGGLE_CLASS: "lm-nav-jiggle",
PANEL_WIDTH: 360,
PREVIEW_LENGTH: 60, // 增加預覽長度
PAGE_SIZE: 20,
// 指示條動態密度參數
INDICATOR_MAX_HEIGHT_RATIO: 0.65, // 最大佔用視窗高度比例
INDICATOR_CROWDED_THRESHOLD: 40, // 開始壓縮的訊息數閾值
// Fisheye 效果參數
FISHEYE_RADIUS_RATIO: 0.2,
FISHEYE_INFLUENCE_COUNT: 3, // ★ 減少影響範圍,讓波浪更緊湊
FISHEYE_MAX_SCALE: 2.2, // ★ 略微降低最大縮放,避免過於誇張
FISHEYE_MAX_SCALE_X: 1.5, // ★ 新增:水平方向的最大縮放
FISHEYE_MAX_GAP_BOOST: 5, // ★ 新增:最大間距增加(px)
FISHEYE_BRIGHTNESS_BOOST: 0.12,
INIT_DELAY: 1800,
UPDATE_DEBOUNCE: 400,
SCROLL_THROTTLE: 80,
JIGGLE_DURATION: 320,
HOVER_SHOW_DELAY: 200,
HOVER_HIDE_DELAY: 350,
PROXIMITY_THRESHOLD: 50,
AUTO_HIDE_DELAY: 600,
DOM_WATCH_INTERVAL: 1500,
// ★ 新增:歷史載入相關
HISTORY_LOAD_MAX_NO_CHANGE: 3, // 無變化最大次數
HISTORY_LOAD_DELAY_INITIAL: 450, // 初始延遲(毫秒)
HISTORY_LOAD_DELAY_RETRY: 650, // 重試延遲(毫秒)
// ★ 新增:撤銷相關
UNDO_MAX_ITEMS: 200, // 支援撤銷的最大項目數
INLINE_UNDO_DURATION: 4000, // 行內撤銷按鈕持續時間(毫秒)
INLINE_UNDO_PAUSE_DURATION: 2500, // 暫停後繼續的時間(毫秒)
TOAST_UNDO_DURATION: 4500, // Toast 撤銷持續時間(毫秒)
TOAST_UNDO_DURATION_LARGE: 5000, // 大量項目撤銷持續時間
// ★ 新增:導航相關
PENDING_NAV_MAX_ATTEMPTS: 20, // SPA 導航最大嘗試次數
PENDING_NAV_DELAY: 300, // SPA 導航延遲(毫秒)
STORAGE_KEY_FAVORITES: "lm_nav_favorites_v3",
STORAGE_KEY_SETTINGS: "lm_nav_settings_v8",
STORAGE_KEY_FAB_POSITION: "lm_nav_fab_pos_v2",
STORAGE_KEY_PANEL_POSITION: "lm_nav_panel_pos_v2",
STORAGE_KEY_INDICATOR_POSITION: "lm_nav_indicator_pos_v1",
STORAGE_KEY_ONBOARDING: "lm_nav_onboarding_v2",
STORAGE_KEY_PANEL_SIZE: "lm_nav_panel_size_v1",
STORAGE_KEY_KEYBINDINGS: "lm_nav_keybindings_v2",
STORAGE_KEY_MESSAGE_TIMES: "lm_nav_message_times_v1",
STORAGE_KEY_RECORDED_IDS: "lm_nav_recorded_ids_v1",
Z_INDEX: {
INDICATOR: 2147483000,
PANEL: 2147483100,
FAB: 2147483200,
MENU: 2147483300,
DIALOG: 2147483400,
TOAST: 2147483647,
},
MSG_LENGTH_THRESHOLDS: { SHORT: 100, MEDIUM: 500, LONG: 2000 },
};
const DEFAULT_SETTINGS = {
fabOpacity: 90,
indicatorOpacity: 85,
fabScale: 100,
fontSize: 13,
fontFamily: 'system',
showFab: true,
showIndicator: true,
autoHideFab: false,
autoHideIndicator: false,
autoCollapseIndicator: false,
enableAnimation: true,
messageDisplayMode: 'user',
showAIInIndicator: true,
paginatePanel: true,
reversedOrder: false,
scrollPosition: 'start',
hoverShowPanel: true,
indicatorShowLength: true,
indicatorShowFavorites: true,
panelView: 'messages',
panelState: 'hidden',
timestampMode: 'record',
timestampShowTime: true,
messageTimestampRetention: 14,
listItemCompact: false,
};
const DEFAULT_FAB_POSITION = { bottom: 24, right: 24 };
const DEFAULT_PANEL_POSITION = { top: null, left: null };
const DEFAULT_INDICATOR_POSITION = { edge: 'right', top: 100 };
const DEFAULT_PANEL_SIZE = { width: 380, height: 450 };
const FONT_OPTIONS = {
system: {
label: '系統預設',
value: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans TC", "PingFang TC", sans-serif'
},
serif: {
label: '襯線體',
value: 'Georgia, "Times New Roman", "Noto Serif TC", serif'
},
mono: {
label: '等寬字型',
value: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace'
},
rounded: {
label: '圓體',
value: '"SF Pro Rounded", "Nunito", "Varela Round", sans-serif'
},
};
const FONT_SIZE_OPTIONS = [
{ label: '小', value: 12 },
{ label: '中', value: 13 },
{ label: '大', value: 14 },
{ label: '特大', value: 15 },
];
const DEFAULT_KEYBINDINGS = {
togglePanel: { key: 'n', alt: true, ctrl: false, shift: false, meta: false },
toggleSettings: { key: 's', alt: true, ctrl: false, shift: false, meta: false },
navigateUp: { key: 'ArrowUp', alt: true, ctrl: false, shift: false, meta: false },
navigateDown: { key: 'ArrowDown', alt: true, ctrl: false, shift: false, meta: false },
navigateFirst: { key: '[', alt: true, ctrl: false, shift: false, meta: false },
navigateLast: { key: ']', alt: true, ctrl: false, shift: false, meta: false },
toggleOrder: { key: 'r', alt: true, ctrl: false, shift: false, meta: false },
loadHistory: { key: 'l', alt: true, ctrl: false, shift: false, meta: false },
toggleView: { key: 'f', alt: true, ctrl: false, shift: false, meta: false },
toggleFavManager: { key: 'm', alt: true, ctrl: false, shift: false, meta: false },
toggleIndicatorEdge: { key: 'i', alt: true, ctrl: false, shift: false, meta: false },
togglePanelPin: { key: 'p', alt: true, ctrl: false, shift: false, meta: false },
toggleAIMessages: { key: 'a', alt: true, ctrl: false, shift: false, meta: false },
cycleDisplayMode: { key: 'd', alt: true, ctrl: false, shift: false, meta: false },
scrollToBottom: { key: 'End', alt: true, ctrl: false, shift: false, meta: false },
};
const KEYBINDING_LABELS = {
togglePanel: '開關導航板',
toggleSettings: '開關設定',
navigateUp: '上一條消息',
navigateDown: '下一條消息',
navigateFirst: '跳到第一條',
navigateLast: '跳到最後一條',
toggleOrder: '切換排序',
loadHistory: '載入歷史',
toggleView: '切換消息/收藏',
toggleFavManager: '開關收藏夾',
toggleIndicatorEdge: '移動指示條',
togglePanelPin: '固定/取消固定導航板',
toggleAIMessages: '顯示/隱藏 AI 回覆',
cycleDisplayMode: '循環切換顯示模式',
scrollToBottom: '跳到頁面底部',
};
// ========================================
// Utilities
// ========================================
const utils = {
debounce(fn, wait) {
let timeout;
return (...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => fn(...args), wait);
};
},
throttle(fn, limit) {
let inThrottle;
return (...args) => {
if (!inThrottle) {
fn(...args);
inThrottle = true;
setTimeout(() => (inThrottle = false), limit);
}
};
},
clamp: (value, min, max) => Math.min(Math.max(value, min), max),
lerp: (a, b, t) => a + (b - a) * t, // 線性插值
smoothstep: (t) => t * t * (3 - 2 * t), // 平滑插值
easeOutCubic: (t) => 1 - Math.pow(1 - t, 3), // 緩出動畫
getElementSide(element) {
if (!element) return 'right';
const rect = element.getBoundingClientRect();
return (rect.left + rect.width / 2) < window.innerWidth / 2 ? 'left' : 'right';
},
calculatePanelPosition(anchor, panelWidth, panelHeight, margin = 12) {
if (!anchor) {
return {
left: window.innerWidth - panelWidth - 20,
top: (window.innerHeight - panelHeight) / 2
};
}
const rect = anchor.getBoundingClientRect();
const side = utils.getElementSide(anchor);
let left = side === 'right' ? rect.left - panelWidth - margin : rect.right + margin;
if (left < margin || left + panelWidth > window.innerWidth - margin) {
left = side === 'right' ? rect.right + margin : rect.left - panelWidth - margin;
}
left = utils.clamp(left, margin, window.innerWidth - panelWidth - margin);
const top = utils.clamp(rect.top, margin, window.innerHeight - panelHeight - margin);
return { left, top };
},
async copyToClipboard(text) {
if (!text || typeof text !== 'string') return false;
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.cssText = 'position:fixed;left:-9999px;top:-9999px;opacity:0;';
document.body.appendChild(textarea);
textarea.select();
const success = document.execCommand('copy');
document.body.removeChild(textarea);
return success;
} catch (e) {
log('copy failed:', e);
return false;
}
},
truncate(text, maxLength) {
if (!text) return '';
text = text.trim();
return text.length <= maxLength ? text : text.substring(0, maxLength).trim() + '…';
},
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
},
highlightText(text, query) {
if (!query) return utils.escapeHtml(text);
const lowerText = text.toLowerCase();
const lowerQuery = query.toLowerCase();
const index = lowerText.indexOf(lowerQuery);
if (index === -1) return utils.escapeHtml(text);
const before = text.substring(0, index);
const match = text.substring(index, index + query.length);
const after = text.substring(index + query.length);
return `${utils.escapeHtml(before)}<mark class="lm-nav-highlight">${utils.escapeHtml(match)}</mark>${utils.escapeHtml(after)}`;
},
formatRelativeTime(timestamp) {
const diff = Date.now() - timestamp;
if (diff < 60000) return '剛剛';
if (diff < 3600000) return `${Math.floor(diff / 60000)} 分鐘前`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小時前`;
if (diff < 604800000) return `${Math.floor(diff / 86400000)} 天前`;
const date = new Date(timestamp);
return `${date.getMonth() + 1}/${date.getDate()}`;
},
formatCompactTime(timestamp) {
const date = new Date(timestamp);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
if (isToday) {
return date.toLocaleTimeString('zh-TW', { hour: '2-digit', minute: '2-digit' });
}
return `${date.getMonth() + 1}/${date.getDate()}`;
},
isSameOrigin(url1, url2) {
try {
const a = new URL(url1);
const b = new URL(url2);
return a.origin === b.origin;
} catch { return false; }
},
// ★ 新增:通用文字雜湊函數
hashText(text, maxLength = 300) {
if (!text) return '';
const normalized = text.substring(0, maxLength).replace(/\s+/g, ' ').trim().toLowerCase();
let hash = 0;
for (let i = 0; i < normalized.length; i++) {
hash = ((hash << 5) - hash) + normalized.charCodeAt(i);
hash = hash & hash;
}
return Math.abs(hash).toString(36);
},
// ★ 新增:設置可訪問性懸停行為
setupAccessibleHover(container, selector = 'button') {
const updateVisibility = (visible) => {
container.querySelectorAll(selector).forEach(el => {
el.tabIndex = visible ? 0 : -1;
if (visible) {
el.removeAttribute('aria-hidden');
} else {
el.setAttribute('aria-hidden', 'true');
}
});
};
container.addEventListener('mouseenter', () => updateVisibility(true));
container.addEventListener('mouseleave', () => updateVisibility(false));
container.addEventListener('focusin', () => updateVisibility(true));
container.addEventListener('focusout', (e) => {
if (!container.contains(e.relatedTarget)) {
updateVisibility(false);
}
});
},
};
// ========================================
// Storage Adapter
// ========================================
const storage = {
_gm4Cache: {},
_gm4Loaded: false,
hasGM() {
return typeof GM_setValue !== 'undefined' && typeof GM_getValue !== 'undefined';
},
hasGM4() {
return typeof GM !== 'undefined' && typeof GM.setValue === 'function' && typeof GM.getValue === 'function';
},
set(key, value) {
try {
if (this.hasGM()) { GM_setValue(key, value); return true; }
if (this.hasGM4()) { GM.setValue(key, value); this._gm4Cache[key] = value; return true; }
localStorage.setItem(key, JSON.stringify(value));
return true;
} catch (e) { log('storage set failed:', e); return false; }
},
get(key, defaultValue = null) {
try {
if (this.hasGM()) return GM_getValue(key, defaultValue);
if (this.hasGM4()) return key in this._gm4Cache ? this._gm4Cache[key] : defaultValue;
const value = localStorage.getItem(key);
return value !== null ? JSON.parse(value) : defaultValue;
} catch (e) { log('storage get failed:', e); return defaultValue; }
},
async preloadGM4() {
if (!this.hasGM4() || this._gm4Loaded) return;
const keys = [
CONFIG.STORAGE_KEY_SETTINGS, CONFIG.STORAGE_KEY_FAVORITES, CONFIG.STORAGE_KEY_FAB_POSITION,
CONFIG.STORAGE_KEY_PANEL_POSITION, CONFIG.STORAGE_KEY_INDICATOR_POSITION, CONFIG.STORAGE_KEY_PANEL_SIZE,
CONFIG.STORAGE_KEY_ONBOARDING, CONFIG.STORAGE_KEY_KEYBINDINGS,
'lm_nav_settings_v7', 'lm_nav_settings_v6', 'lm_nav_settings_v5',
];
try {
const results = await Promise.all(keys.map(k => GM.getValue(k)));
keys.forEach((k, i) => { if (results[i] !== undefined) this._gm4Cache[k] = results[i]; });
this._gm4Loaded = true;
} catch (e) { log('GM4 preload failed:', e); }
},
remove(key) {
try {
if (this.hasGM() && typeof GM_deleteValue === 'function') GM_deleteValue(key);
else if (this.hasGM4() && typeof GM.deleteValue === 'function') {
GM.deleteValue(key);
delete this._gm4Cache[key];
}
else localStorage.removeItem(key);
} catch (e) { log('storage remove failed:', e); }
},
};
const addStyle = (css) => {
if (typeof GM_addStyle === 'function') {
try { GM_addStyle(css); return true; } catch (e) {}
}
if (typeof GM !== 'undefined' && typeof GM.addStyle === 'function') {
try { GM.addStyle(css); return true; } catch (e) {}
}
try {
const style = document.createElement('style');
style.setAttribute('type', 'text/css');
style.setAttribute('data-source', 'lm-nav-injected');
style.textContent = css;
(document.head || document.documentElement).appendChild(style);
return true;
} catch (e) { return false; }
};
const registerMenuCommands = (app) => {
let register = typeof GM_registerMenuCommand === 'function' ? GM_registerMenuCommand :
(typeof GM !== 'undefined' && typeof GM.registerMenuCommand === 'function' ? (...args) => GM.registerMenuCommand(...args) : null);
if (!register) return;
try {
register('📋 開關導航板', () => app.togglePanel());
register('⚙️ 開關設定', () => app.toggleSettings());
register('⭐ 開關收藏夾', () => app.toggleFavoriteManager());
register('🔄 重新載入消息', () => { app.updateMessages(); app._toast?.success?.('已重新載入'); });
register('📍 重置所有位置', () => { app.resetAllPositions(); app._toast?.success?.('位置已重置'); });
} catch (e) {}
};
// ========================================
// Settings Manager
// ========================================
const Settings = {
_cache: null,
_listeners: [],
load() {
if (this._cache) return this._cache;
const saved = storage.get(CONFIG.STORAGE_KEY_SETTINGS, null);
this._cache = { ...DEFAULT_SETTINGS, ...(saved || {}) };
// 遷移舊版設定
this._migrateIfNeeded();
return this._cache;
},
_migrateIfNeeded() {
let needsSave = false;
// 從 v7 遷移
if (this._cache.navMode !== undefined) {
switch (this._cache.navMode) {
case 'fab':
this._cache.showFab = true;
this._cache.showIndicator = false;
break;
case 'indicator':
this._cache.showFab = false;
this._cache.showIndicator = true;
break;
case 'minimal':
this._cache.showFab = true;
this._cache.showIndicator = true;
this._cache.autoHideFab = true;
this._cache.autoHideIndicator = true;
break;
}
delete this._cache.navMode;
needsSave = true;
}
// 從 showAIMessages 遷移到 messageDisplayMode
if (this._cache.showAIMessages !== undefined && this._cache.messageDisplayMode === undefined) {
this._cache.messageDisplayMode = this._cache.showAIMessages ? 'both' : 'user';
delete this._cache.showAIMessages;
needsSave = true;
}
// 確保所有新欄位有預設值
const defaults = [
['showAIInIndicator', true],
['indicatorShowLength', true],
['indicatorShowFavorites', true],
['autoCollapseIndicator', false],
['listItemCompact', false],
];
for (const [key, defaultVal] of defaults) {
if (this._cache[key] === undefined) {
this._cache[key] = defaultVal;
needsSave = true;
}
}
// 驗證 messageDisplayMode
if (!['user', 'ai', 'both'].includes(this._cache.messageDisplayMode)) {
this._cache.messageDisplayMode = 'user';
needsSave = true;
}
if (needsSave) {
storage.set(CONFIG.STORAGE_KEY_SETTINGS, this._cache);
}
},
get(key) {
return this.load()[key];
},
set(key, value) {
const settings = this.load();
const oldValue = settings[key];
if (oldValue === value) return;
settings[key] = value;
this._cache = settings;
storage.set(CONFIG.STORAGE_KEY_SETTINGS, settings);
this._notify(key, value, oldValue);
},
getAll() {
return { ...this.load() };
},
reset() {
this._cache = { ...DEFAULT_SETTINGS };
storage.set(CONFIG.STORAGE_KEY_SETTINGS, this._cache);
this._notify('reset', this._cache, null);
},
onChange(callback) {
this._listeners.push(callback);
return () => {
const idx = this._listeners.indexOf(callback);
if (idx >= 0) this._listeners.splice(idx, 1);
};
},
_notify(key, newValue, oldValue) {
this._listeners.forEach(fn => {
try { fn(key, newValue, oldValue); } catch (e) {}
});
},
getFontFamily() {
const key = this.get('fontFamily') || 'system';
return FONT_OPTIONS[key]?.value || FONT_OPTIONS.system.value;
},
invalidateCache() {
this._cache = null;
},
};
// ========================================
// Keybindings Manager
// ========================================
const Keybindings = {
_cache: null,
_listeners: [],
load() {
if (this._cache) return this._cache;
const saved = storage.get(CONFIG.STORAGE_KEY_KEYBINDINGS, null);
this._cache = { ...DEFAULT_KEYBINDINGS, ...(saved || {}) };
if (this._cache.showSettings && !this._cache.toggleSettings) {
this._cache.toggleSettings = this._cache.showSettings;
delete this._cache.showSettings;
}
return this._cache;
},
get(action) { return this.load()[action]; },
getAll() { return { ...this.load() }; },
set(action, binding) {
const bindings = this.load();
bindings[action] = binding;
this._cache = bindings;
storage.set(CONFIG.STORAGE_KEY_KEYBINDINGS, bindings);
this._notify(action, binding);
},
reset() {
this._cache = { ...DEFAULT_KEYBINDINGS };
storage.set(CONFIG.STORAGE_KEY_KEYBINDINGS, this._cache);
this._notify('reset', this._cache);
},
resetAction(action) {
if (DEFAULT_KEYBINDINGS[action]) this.set(action, { ...DEFAULT_KEYBINDINGS[action] });
},
onChange(callback) {
this._listeners.push(callback);
return () => {
const idx = this._listeners.indexOf(callback);
if (idx >= 0) this._listeners.splice(idx, 1);
};
},
_notify(action, binding) {
this._listeners.forEach(fn => { try { fn(action, binding); } catch (e) {} });
},
formatBinding(binding) {
if (!binding || !binding.key) return '無';
const parts = [];
if (binding.ctrl) parts.push('Ctrl');
if (binding.alt) parts.push('Alt');
if (binding.shift) parts.push('Shift');
if (binding.meta) parts.push('⌘');
let keyName = binding.key;
const keyDisplayMap = {
ArrowUp: '↑', ArrowDown: '↓', ArrowLeft: '←', ArrowRight: '→',
Escape: 'Esc', ' ': 'Space'
};
if (keyDisplayMap[keyName]) keyName = keyDisplayMap[keyName];
else if (keyName.length === 1) keyName = keyName.toUpperCase();
parts.push(keyName);
return parts.join('+');
},
matchesEvent(binding, event) {
if (!binding || !binding.key) return false;
const keyMatches = event.key.toLowerCase() === binding.key.toLowerCase() || event.key === binding.key;
return keyMatches &&
event.altKey === !!binding.alt &&
event.ctrlKey === !!binding.ctrl &&
event.shiftKey === !!binding.shift &&
event.metaKey === !!binding.meta;
},
fromEvent(event) {
return {
key: event.key,
alt: event.altKey,
ctrl: event.ctrlKey,
shift: event.shiftKey,
meta: event.metaKey
};
},
findConflict(action, binding) {
const all = this.load();
for (const [otherAction, otherBinding] of Object.entries(all)) {
if (otherAction === action) continue;
if (this._bindingsEqual(binding, otherBinding)) return otherAction;
}
return null;
},
_bindingsEqual(a, b) {
if (!a || !b) return false;
return a.key?.toLowerCase() === b.key?.toLowerCase() &&
!!a.alt === !!b.alt &&
!!a.ctrl === !!b.ctrl &&
!!a.shift === !!b.shift &&
!!a.meta === !!b.meta;
},
invalidateCache() { this._cache = null; },
};
// ========================================
// Position Managers
// ========================================
const createPositionManager = (storageKey, defaultPos, validator) => ({
_cache: null,
load() {
if (this._cache) return this._cache;
const saved = storage.get(storageKey, null);
this._cache = { ...defaultPos, ...(saved || {}) };
return this._cache;
},
save(pos) {
this._cache = validator(pos);
storage.set(storageKey, this._cache);
},
reset() {
this._cache = { ...defaultPos };
storage.set(storageKey, this._cache);
return this._cache;
},
invalidateCache() { this._cache = null; },
});
const FabPosition = createPositionManager(
CONFIG.STORAGE_KEY_FAB_POSITION,
DEFAULT_FAB_POSITION,
(pos) => ({
bottom: utils.clamp(pos.bottom || 24, 10, window.innerHeight - 70),
right: utils.clamp(pos.right || 24, 10, window.innerWidth - 70),
})
);
const PanelPosition = createPositionManager(
CONFIG.STORAGE_KEY_PANEL_POSITION,
DEFAULT_PANEL_POSITION,
(pos) => ({
top: pos.top != null ? utils.clamp(pos.top, 10, window.innerHeight - 100) : null,
left: pos.left != null ? utils.clamp(pos.left, 10, window.innerWidth - 100) : null,
})
);
const PanelSize = createPositionManager(
CONFIG.STORAGE_KEY_PANEL_SIZE,
DEFAULT_PANEL_SIZE,
(size) => ({
width: utils.clamp(size.width || 380, 300, 600),
height: utils.clamp(size.height || 450, 280, 800),
})
);
const IndicatorPosition = {
_cache: null,
load() {
if (this._cache) return this._cache;
const saved = storage.get(CONFIG.STORAGE_KEY_INDICATOR_POSITION, null);
this._cache = { ...DEFAULT_INDICATOR_POSITION, ...(saved || {}) };
return this._cache;
},
save(pos) {
this._cache = {
edge: pos.edge === 'left' ? 'left' : 'right',
top: utils.clamp(pos.top || 100, 20, window.innerHeight - 200)
};
storage.set(CONFIG.STORAGE_KEY_INDICATOR_POSITION, this._cache);
},
reset() {
this._cache = { ...DEFAULT_INDICATOR_POSITION };
storage.set(CONFIG.STORAGE_KEY_INDICATOR_POSITION, this._cache);
return this._cache;
},
getStyles() {
const pos = this.load();
return pos.edge === 'left'
? { top: `${pos.top}px`, left: '8px', right: 'auto' }
: { top: `${pos.top}px`, right: '8px', left: 'auto' };
},
toggleEdge() {
const pos = this.load();
pos.edge = pos.edge === 'left' ? 'right' : 'left';
this.save(pos);
return pos;
},
invalidateCache() { this._cache = null; },
};
// ========================================
// Theme Manager
// ========================================
const Theme = {
_isDark: null,
_listeners: [],
detect() {
const htmlDark = document.documentElement.classList.contains('dark');
const bodyDark = document.body?.classList.contains('dark');
let bgDark = false;
if (document.body) {
const bg = window.getComputedStyle(document.body).backgroundColor;
const rgb = bg.match(/\d+/g);
if (rgb && rgb.length >= 3) {
const brightness = (parseInt(rgb[0]) * 299 + parseInt(rgb[1]) * 587 + parseInt(rgb[2]) * 114) / 1000;
bgDark = brightness < 128;
}
}
const wasDark = this._isDark;
this._isDark = htmlDark || bodyDark || bgDark;
if (wasDark !== null && wasDark !== this._isDark) this._notify();
return this._isDark;
},
get isDark() {
if (this._isDark === null) this.detect();
return this._isDark;
},
onChange(callback) {
this._listeners.push(callback);
return () => {
const idx = this._listeners.indexOf(callback);
if (idx >= 0) this._listeners.splice(idx, 1);
};
},
_notify() {
this._listeners.forEach(fn => { try { fn(this._isDark); } catch (e) {} });
},
};
// ========================================
// Onboarding
// ========================================
const Onboarding = {
hasCompleted() { return storage.get(CONFIG.STORAGE_KEY_ONBOARDING, false); },
markCompleted() { storage.set(CONFIG.STORAGE_KEY_ONBOARDING, true); },
show(anchorElement) {
if (this.hasCompleted()) return;
const tip = document.createElement('div');
tip.className = 'lm-nav-onboarding';
tip.innerHTML = `
<div class="lm-nav-onboarding-content">
<div class="lm-nav-onboarding-header">
<span class="lm-nav-onboarding-icon">💬</span>
<span class="lm-nav-onboarding-title">對話導航器</span>
</div>
<ul class="lm-nav-onboarding-list">
<li>點擊浮動鈕或懸停指示條開啟導航板</li>
<li>拖動浮動鈕、指示條、導航板調整位置</li>
<li>右鍵點擊可開啟更多選項</li>
<li>快捷鍵可在設定中自訂</li>
</ul>
<button class="lm-nav-onboarding-close">知道了</button>
</div>`;
tip.querySelector('.lm-nav-onboarding-close').addEventListener('click', () => {
tip.classList.add('lm-nav-onboarding--hiding');
setTimeout(() => tip.remove(), 200);
this.markCompleted();
});
if (anchorElement) {
const rect = anchorElement.getBoundingClientRect();
const tipWidth = 280;
if (rect.left > tipWidth + 20) tip.style.right = `${window.innerWidth - rect.left + 12}px`;
else tip.style.left = `${rect.right + 12}px`;
tip.style.bottom = `${window.innerHeight - rect.bottom}px`;
} else {
tip.style.right = '80px';
tip.style.bottom = '24px';
}
const host = document.getElementById(CONFIG.CONTAINER_ID) || document.body;
host.appendChild(tip);
requestAnimationFrame(() => tip.classList.add('lm-nav-onboarding--visible'));
setTimeout(() => {
if (tip.parentNode && !this.hasCompleted()) {
tip.classList.add('lm-nav-onboarding--hiding');
setTimeout(() => tip.remove(), 200);
this.markCompleted();
}
}, 15000);
},
};
// ========================================
// Favorites Manager
// ========================================
const Favorites = {
_cache: null,
_listeners: [],
getPageId() { return location.pathname; },
getPageTitle() {
const h1 = document.querySelector('h1');
if (h1?.textContent?.trim()) return h1.textContent.trim().substring(0, 50);
return document.title || this.getPageId();
},
_loadAll() {
if (this._cache) return this._cache;
this._cache = storage.get(CONFIG.STORAGE_KEY_FAVORITES, {});
return this._cache;
},
_saveAll() { storage.set(CONFIG.STORAGE_KEY_FAVORITES, this._cache); },
getForCurrentPage() { return this._loadAll()[this.getPageId()] || []; },
getAllPages() { return this._loadAll(); },
getTotalCount() { return Object.values(this._loadAll()).reduce((sum, arr) => sum + arr.length, 0); },
getCurrentPageCount() { return this.getForCurrentPage().length; },
has(msgId) { return this.getForCurrentPage().some(item => item.id === msgId); },
add(msgId, text, msgType = 'user', meta = {}) {
if (!msgId || !text) return false;
const all = this._loadAll();
const pageId = this.getPageId();
if (!all[pageId]) all[pageId] = [];
if (all[pageId].some(item => item.id === msgId)) return false;
all[pageId].push({
id: msgId,
text: text.substring(0, 2000),
preview: utils.truncate(text, 80),
type: msgType,
pageId,
pageTitle: this.getPageTitle(),
url: location.href,
timestamp: Date.now(),
originalIndex: meta.index ?? -1,
textHash: this._hashText(text),
});
this._saveAll();
this._notify('add', { id: msgId });
return true;
},
_hashText(text) {
return utils.hashText(text, 300);
},
remove(msgId) {
const all = this._loadAll();
const pageId = this.getPageId();
if (!all[pageId]) return null;
const index = all[pageId].findIndex(item => item.id === msgId);
if (index === -1) return null;
const removed = all[pageId].splice(index, 1)[0];
if (all[pageId].length === 0) delete all[pageId];
this._saveAll();
this._notify('remove', removed);
return removed;
},
removeFromPage(pageId, msgId) {
const all = this._loadAll();
if (!all[pageId]) return null;
const index = all[pageId].findIndex(item => item.id === msgId);
if (index === -1) return null;
const removed = all[pageId].splice(index, 1)[0];
if (all[pageId].length === 0) delete all[pageId];
this._saveAll();
this._notify('remove', removed);
return removed;
},
removeMultiple(items) {
if (!items?.length) return [];
const all = this._loadAll();
const removed = [];
items.forEach(({ pageId, id }) => {
if (!all[pageId]) return;
const index = all[pageId].findIndex(item => item.id === id);
if (index !== -1) {
removed.push(all[pageId].splice(index, 1)[0]);
if (all[pageId].length === 0) delete all[pageId];
}
});
if (removed.length > 0) {
this._saveAll();
this._notify('removeMultiple', removed);
}
return removed;
},
clearPage(pageId) {
const all = this._loadAll();
if (!all[pageId]) return [];
const removed = [...all[pageId]];
delete all[pageId];
this._saveAll();
this._notify('clearPage', { pageId, items: removed });
return removed;
},
clearAll() {
const all = this._loadAll();
const totalRemoved = Object.values(all).flat();
this._cache = {};
this._saveAll();
this._notify('clearAll', totalRemoved);
return totalRemoved;
},
toggle(msgId, text, msgType = 'user', meta = {}) {
if (this.has(msgId)) return { action: 'removed', item: this.remove(msgId) };
this.add(msgId, text, msgType, meta);
return { action: 'added', item: null };
},
undoRemove(item) {
if (!item) return false;
const all = this._loadAll();
if (!all[item.pageId]) all[item.pageId] = [];
if (all[item.pageId].some(i => i.id === item.id)) return false;
all[item.pageId].push(item);
this._saveAll();
this._notify('undo', item);
return true;
},
undoRemoveMultiple(items) {
if (!items?.length) return 0;
const all = this._loadAll();
let restored = 0;
items.forEach(item => {
if (!all[item.pageId]) all[item.pageId] = [];
if (!all[item.pageId].some(i => i.id === item.id)) {
all[item.pageId].push(item);
restored++;
}
});
if (restored > 0) {
this._saveAll();
this._notify('undoMultiple', items);
}
return restored;
},
undoRemoveAt(item, index) {
if (!item) return false;
const all = this._loadAll();
if (!all[item.pageId]) all[item.pageId] = [];
if (all[item.pageId].some(i => i.id === item.id)) return false;
const idx = (typeof index === 'number' && index >= 0)
? Math.min(index, all[item.pageId].length)
: all[item.pageId].length;
all[item.pageId].splice(idx, 0, item);
this._saveAll();
this._notify('undo', item);
return true;
},
invalidate() { this._cache = null; },
onChange(callback) {
this._listeners.push(callback);
return () => {
const idx = this._listeners.indexOf(callback);
if (idx >= 0) this._listeners.splice(idx, 1);
};
},
findMatchingMessage(messages, favItem) {
if (!messages || !favItem) return null;
let msg = messages.find(m => m.matchesId(favItem.id));
if (msg && this._verifyTextMatch(msg, favItem)) return msg;
msg = messages.find(m => m._legacyId === favItem.id);
if (msg && this._verifyTextMatch(msg, favItem)) return msg;
if (favItem.textHash) {
msg = messages.find(m => m._contentHash === favItem.textHash && m.type === (favItem.type || 'user'));
if (msg && this._verifyTextMatch(msg, favItem)) return msg;
}
if (favItem.text) {
const favFingerprint = favItem.text.substring(0, 80).replace(/\s+/g, ' ').trim().toLowerCase();
const candidates = messages.filter(m =>
m.getTextFingerprint() === favFingerprint &&
m.type === (favItem.type || 'user')
);
if (candidates.length === 1) return candidates[0];
}
if (favItem.preview) {
const previewLower = favItem.preview.toLowerCase().replace(/…$/, '').replace(/\.{3}$/, '');
const candidates = messages.filter(m =>
m.text.toLowerCase().startsWith(previewLower) &&
m.type === (favItem.type || 'user')
);
if (candidates.length === 1) return candidates[0];
}
return null;
},
_verifyTextMatch(msg, favItem) {
if (!favItem.text) return true;
const msgStart = msg.text.substring(0, 100).replace(/\s+/g, ' ').trim().toLowerCase();
const favStart = favItem.text.substring(0, 100).replace(/\s+/g, ' ').trim().toLowerCase();
return msgStart === favStart;
},
updateFavoriteId(pageId, oldId, newId) {
const all = this._loadAll();
if (!all[pageId]) return false;
const item = all[pageId].find(f => f.id === oldId);
if (item) {
item.id = newId;
this._saveAll();
return true;
}
return false;
},
_notify(action, data) {
this._listeners.forEach(fn => { try { fn(action, data); } catch (e) {} });
},
};
// ========================================
// Message Timestamps
// ========================================
const MessageTimestamps = {
_cache: null,
_recordedIds: null,
_loadData() {
if (this._cache) return this._cache;
this._cache = storage.get(CONFIG.STORAGE_KEY_MESSAGE_TIMES, {});
return this._cache;
},
_loadRecordedIds() {
if (this._recordedIds) return this._recordedIds;
const saved = storage.get(CONFIG.STORAGE_KEY_RECORDED_IDS, []);
this._recordedIds = new Set(saved);
return this._recordedIds;
},
_saveData() {
storage.set(CONFIG.STORAGE_KEY_MESSAGE_TIMES, this._cache);
},
_saveRecordedIds() {
storage.set(CONFIG.STORAGE_KEY_RECORDED_IDS, [...this._recordedIds]);
},
_getStableId(pageId, msgText) {
const textHash = this._hashText(msgText);
return `${pageId}|${textHash}`;
},
recordIfNew(pageId, msgId, msgText) {
const mode = Settings.get('timestampMode');
if (mode === 'none') return;
const stableId = this._getStableId(pageId, msgText);
const recordedIds = this._loadRecordedIds();
if (recordedIds.has(stableId)) return;
const data = this._loadData();
if (!data[pageId]) data[pageId] = {};
if (!data[pageId][stableId]) {
data[pageId][stableId] = {
timestamp: Date.now(),
msgId: msgId,
textHash: this._hashText(msgText),
};
this._saveData();
}
recordedIds.add(stableId);
this._saveRecordedIds();
},
getTime(pageId, msgText) {
const data = this._loadData();
if (!data[pageId]) return null;
const stableId = this._getStableId(pageId, msgText);
return data[pageId][stableId]?.timestamp || null;
},
formatTimestamp(ts) {
if (!ts) return '';
const showTime = Settings.get('timestampShowTime');
const d = new Date(ts);
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
if (showTime) {
const hh = String(d.getHours()).padStart(2, '0');
const mi = String(d.getMinutes()).padStart(2, '0');
return `${mm}/${dd} ${hh}:${mi}`;
} else {
return `${mm}/${dd}`;
}
},
cleanupExpired() {
const retentionDays = Settings.get('messageTimestampRetention');
// ★ 負數或 undefined 表示永久保留,不清理
if (retentionDays < 0 || retentionDays === undefined || retentionDays === null) {
return;
}
// ★ 0 天表示不保留任何歷史記錄
if (retentionDays === 0) {
this.clearAll();
return;
}
const data = this._loadData();
const cutoff = Date.now() - (retentionDays * 24 * 60 * 60 * 1000);
let changed = false;
for (const pageId in data) {
for (const key in data[pageId]) {
if (data[pageId][key].timestamp < cutoff) {
delete data[pageId][key];
changed = true;
}
}
if (Object.keys(data[pageId]).length === 0) {
delete data[pageId];
}
}
if (changed) this._saveData();
},
clearAll() {
this._cache = {};
this._recordedIds = new Set();
storage.set(CONFIG.STORAGE_KEY_MESSAGE_TIMES, {});
storage.set(CONFIG.STORAGE_KEY_RECORDED_IDS, []);
},
_hashText(text) {
return utils.hashText(text, 200);
},
invalidateCache() {
this._cache = null;
this._recordedIds = null;
}
};
// ========================================
// Icons
// ========================================
// Icons 對象的所有方法包括:
// _svg, chat, list, search, settings, starOutline, starFilled,
// copy, check, download, folderOpen, sortAsc, sortDesc, user, bot,
// close, externalLink, trash, mapPin, chevronLeft, chevronRight,
// chevronDown, cornerDownLeft, undo, info, arrowLeftRight,
// messageSquare, pin, pinOff, palette, sliders, keyboard, edit,
// eye, checkSquare, square, minusSquare, alertTriangle, minimize
const Icons = {
size: { xs: 12, sm: 14, md: 16, lg: 18, xl: 20 },
_svg(size, content, options = {}) {
const s = this.size[size] || this.size.md;
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('width', s);
svg.setAttribute('height', s);
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('fill', 'none');
svg.setAttribute('stroke', 'currentColor');
svg.setAttribute('stroke-width', options.strokeWidth || '2');
svg.setAttribute('stroke-linecap', 'round');
svg.setAttribute('stroke-linejoin', 'round');
svg.setAttribute('aria-hidden', 'true');
svg.innerHTML = content;
return svg;
},
chat(size, opts) { return this._svg(size, `<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>`, opts); },
list(size, opts) { return this._svg(size, `<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>`, opts); },
search(size, opts) { return this._svg(size, `<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>`, opts); },
settings(size, opts) { return this._svg(size, `<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>`, opts); },
starOutline(size, opts) { return this._svg(size, `<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>`, opts); },
starFilled(size, opts) { return this._svg(size, `<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" fill="currentColor"/>`, opts); },
copy(size, opts) { return this._svg(size, `<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>`, opts); },
check(size, opts) { return this._svg(size, `<polyline points="20 6 9 17 4 12"/>`, opts); },
download(size, opts) { return this._svg(size, `<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>`, opts); },
folderOpen(size, opts) { return this._svg(size, `<path d="M5 19a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2v1"/><path d="M5 19h14a2 2 0 0 0 2-2l1-7H4l1 7a2 2 0 0 0 2 2z"/>`, opts); },
sortAsc(size, opts) { return this._svg(size, `<line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/>`, opts); },
sortDesc(size, opts) { return this._svg(size, `<line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/>`, opts); },
user(size, opts) { return this._svg(size, `<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>`, opts); },
bot(size, opts) { return this._svg(size, `<rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><line x1="8" y1="16" x2="8" y2="16"/><line x1="16" y1="16" x2="16" y2="16"/>`, { ...opts, strokeWidth: '1.5' }); },
close(size, opts) { return this._svg(size, `<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>`, opts); },
externalLink(size, opts) { return this._svg(size, `<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>`, opts); },
trash(size, opts) { return this._svg(size, `<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/>`, opts); },
mapPin(size, opts) { return this._svg(size, `<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/>`, opts); },
chevronLeft(size, opts) { return this._svg(size, `<polyline points="15 18 9 12 15 6"/>`, opts); },
chevronRight(size, opts) { return this._svg(size, `<polyline points="9 18 15 12 9 6"/>`, opts); },
chevronDown(size, opts) { return this._svg(size, `<polyline points="6 9 12 15 18 9"/>`, opts); },
cornerDownLeft(size, opts) { return this._svg(size, `<polyline points="9 10 4 15 9 20"/><path d="M20 4v7a4 4 0 0 1-4 4H4"/>`, opts); },
undo(size, opts) { return this._svg(size, `<polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/>`, opts); },
info(size, opts) { return this._svg(size, `<circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/>`, opts); },
arrowLeftRight(size, opts) { return this._svg(size, `<polyline points="7 16 3 12 7 8"/><polyline points="17 8 21 12 17 16"/><line x1="3" y1="12" x2="21" y2="12"/>`, opts); },
messageSquare(size, opts) { return this._svg(size, `<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>`, opts); },
pin(size, opts) { return this._svg(size, `<line x1="12" y1="17" x2="12" y2="22"/><path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24Z"/>`, opts); },
pinOff(size, opts) { return this._svg(size, `<line x1="2" y1="2" x2="22" y2="22"/><line x1="12" y1="17" x2="12" y2="22"/><path d="M9 9v1.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V9"/><path d="M9.3 4H8a2 2 0 0 0 0 4h.3"/><path d="M14.7 4H16a2 2 0 0 1 0 4h-.3"/>`, opts); },
palette(size, opts) { return this._svg(size, `<circle cx="13.5" cy="6.5" r=".5"/><circle cx="17.5" cy="10.5" r=".5"/><circle cx="8.5" cy="7.5" r=".5"/><circle cx="6.5" cy="12.5" r=".5"/><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.555C21.965 6.012 17.461 2 12 2z"/>`, opts); },
sliders(size, opts) { return this._svg(size, `<line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/>`, opts); },
keyboard(size, opts) { return this._svg(size, `<rect x="2" y="4" width="20" height="16" rx="2" ry="2"/><path d="M6 8h.001"/><path d="M10 8h.001"/><path d="M14 8h.001"/><path d="M18 8h.001"/><path d="M8 12h.001"/><path d="M12 12h.001"/><path d="M16 12h.001"/><path d="M7 16h10"/>`, opts); },
edit(size, opts) { return this._svg(size, `<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>`, opts); },
eye(size, opts) { return this._svg(size, `<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>`, opts); },
checkSquare(size, opts) { return this._svg(size, `<polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>`, opts); },
square(size, opts) { return this._svg(size, `<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>`, opts); },
minusSquare(size, opts) { return this._svg(size, `<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="8" y1="12" x2="16" y2="12"/>`, opts); },
alertTriangle(size, opts) { return this._svg(size, `<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>`, opts); },
minimize(size, opts) { return this._svg(size, `<polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/>`, opts); },
};
// ========================================
// Toast
// ========================================
class Toast {
constructor() {
this.container = null;
Theme.onChange((isDark) => {
if (this.container) this.container.dataset.theme = isDark ? 'dark' : 'light';
});
}
_ensureContainer() {
if (this.container && document.body.contains(this.container)) return;
this.container = document.createElement('div');
this.container.className = 'lm-nav-toast-container';
this.container.setAttribute('role', 'alert');
this.container.setAttribute('aria-live', 'polite');
this.container.dataset.theme = Theme.isDark ? 'dark' : 'light';
// 優先掛載到 nav container 以繼承 CSS 變數
const navContainer = document.getElementById(CONFIG.CONTAINER_ID);
if (navContainer) {
navContainer.appendChild(this.container);
} else {
document.body.appendChild(this.container);
}
this.container.style.zIndex = String(CONFIG.Z_INDEX.TOAST);
}
show(message, options = {}) {
const { type = 'info', duration = 2500, action = null } = options;
this._ensureContainer();
const toast = document.createElement('div');
toast.className = `lm-nav-toast lm-nav-toast--${type}`;
const icon = document.createElement('span');
icon.className = 'lm-nav-toast-icon';
const iconMap = {
success: Icons.check('sm'),
error: Icons.close('sm'),
warning: Icons.alertTriangle('sm'),
info: Icons.info('sm'),
};
icon.appendChild(iconMap[type] || iconMap.info);
const text = document.createElement('span');
text.className = 'lm-nav-toast-text';
text.textContent = message;
toast.appendChild(icon);
toast.appendChild(text);
if (action) {
const btn = document.createElement('button');
btn.className = 'lm-nav-toast-action';
btn.textContent = action.label;
btn.addEventListener('click', () => {
action.callback();
this._dismiss(toast);
});
toast.appendChild(btn);
}
this.container.appendChild(toast);
// 使用 RAF 確保動畫觸發
requestAnimationFrame(() => {
requestAnimationFrame(() => {
toast.classList.add('lm-nav-toast--visible');
});
});
toast._duration = duration;
toast._startTime = Date.now();
toast._timer = setTimeout(() => this._dismiss(toast), duration);
// 懸停暫停
toast.addEventListener('mouseenter', () => {
clearTimeout(toast._timer);
toast._remainingTime = Math.max(toast._duration - (Date.now() - toast._startTime), 1000);
});
toast.addEventListener('mouseleave', () => {
toast._startTime = Date.now();
toast._duration = toast._remainingTime || 1500;
toast._timer = setTimeout(() => this._dismiss(toast), toast._duration);
});
return toast;
}
_dismiss(toast) {
if (!toast?.parentNode) return;
clearTimeout(toast._timer);
toast.classList.remove('lm-nav-toast--visible');
toast.classList.add('lm-nav-toast--hiding');
toast.addEventListener('transitionend', () => toast.remove(), { once: true });
setTimeout(() => { if (toast.parentNode) toast.remove(); }, 300);
}
success(message, duration = 2500) { return this.show(message, { type: 'success', duration }); }
error(message, duration = 3000) { return this.show(message, { type: 'error', duration }); }
info(message, duration = 2500) { return this.show(message, { type: 'info', duration }); }
warning(message, duration = 3000) { return this.show(message, { type: 'warning', duration }); }
withUndo(message, undoCallback, duration = 4500) {
return this.show(message, { type: 'info', duration, action: { label: '撤銷', callback: undoCallback } });
}
}
const toast = new Toast();
// ========================================
// ContextMenu
// ========================================
// ContextMenu 類的實現包括:
// constructor, _create, show, hide, _build, _position,
// _onClickOutside, _onKeyDown, _focusNext, _focusPrev
class ContextMenu {
constructor() {
this.element = null;
this.isVisible = false;
this._boundHandlers = {
clickOutside: this._onClickOutside.bind(this),
scroll: () => this.hide(),
keydown: this._onKeyDown.bind(this),
};
}
_create() {
if (this.element && document.body.contains(this.element)) return;
this.element = document.createElement('div');
this.element.className = 'lm-nav-context-menu';
this.element.setAttribute('role', 'menu');
this.element.setAttribute('aria-label', '操作菜單');
const navContainer = document.getElementById(CONFIG.CONTAINER_ID);
(navContainer || document.body).appendChild(this.element);
}
show(x, y, items) {
this._create();
this._build(items);
this._position(x, y);
this.element.style.display = 'block';
this.isVisible = true;
requestAnimationFrame(() => {
document.addEventListener('click', this._boundHandlers.clickOutside, true);
document.addEventListener('contextmenu', this._boundHandlers.clickOutside, true);
document.addEventListener('scroll', this._boundHandlers.scroll, true);
document.addEventListener('keydown', this._boundHandlers.keydown);
});
}
hide() {
if (!this.element || !this.isVisible) return;
this.element.style.display = 'none';
this.isVisible = false;
document.removeEventListener('click', this._boundHandlers.clickOutside, true);
document.removeEventListener('contextmenu', this._boundHandlers.clickOutside, true);
document.removeEventListener('scroll', this._boundHandlers.scroll, true);
document.removeEventListener('keydown', this._boundHandlers.keydown);
}
_build(items) {
this.element.innerHTML = '';
let firstFocusable = null;
items.forEach((item) => {
if (item.separator) {
const sep = document.createElement('div');
sep.className = 'lm-nav-context-separator';
sep.setAttribute('role', 'separator');
this.element.appendChild(sep);
return;
}
const el = document.createElement('div');
el.className = 'lm-nav-context-item';
el.setAttribute('role', 'menuitem');
el.setAttribute('tabindex', '0');
if (item.disabled) {
el.classList.add('lm-nav-context-item--disabled');
el.setAttribute('aria-disabled', 'true');
} else if (!firstFocusable) {
firstFocusable = el;
}
const iconWrap = document.createElement('span');
iconWrap.className = 'lm-nav-context-icon';
if (item.icon) iconWrap.appendChild(typeof item.icon === 'function' ? item.icon('sm') : item.icon);
const label = document.createElement('span');
label.className = 'lm-nav-context-label';
label.textContent = item.label;
const shortcut = document.createElement('span');
shortcut.className = 'lm-nav-context-shortcut';
if (item.shortcut) shortcut.textContent = item.shortcut;
el.appendChild(iconWrap);
el.appendChild(label);
el.appendChild(shortcut);
if (!item.disabled && item.action) {
const execute = (e) => {
e.preventDefault();
e.stopPropagation();
this.hide();
setTimeout(() => item.action(), 16);
};
el.addEventListener('click', execute);
el.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') execute(e);
});
}
this.element.appendChild(el);
});
if (firstFocusable) setTimeout(() => firstFocusable.focus(), 16);
}
_position(x, y) {
this.element.style.visibility = 'hidden';
this.element.style.display = 'block';
const rect = this.element.getBoundingClientRect();
const vw = window.innerWidth, vh = window.innerHeight, margin = 8;
let finalX = x, finalY = y;
if (x + rect.width > vw - margin) finalX = vw - rect.width - margin;
if (finalX < margin) finalX = margin;
if (y + rect.height > vh - margin) finalY = y - rect.height;
if (finalY < margin) finalY = margin;
this.element.style.left = `${finalX}px`;
this.element.style.top = `${finalY}px`;
this.element.style.visibility = 'visible';
}
_onClickOutside(e) {
if (this.isVisible && this.element && !this.element.contains(e.target)) this.hide();
}
_onKeyDown(e) {
if (!this.isVisible) return;
if (e.key === 'Escape') { this.hide(); e.preventDefault(); }
else if (e.key === 'ArrowDown') { this._focusNext(); e.preventDefault(); }
else if (e.key === 'ArrowUp') { this._focusPrev(); e.preventDefault(); }
}
_focusNext() {
const items = Array.from(this.element.querySelectorAll('.lm-nav-context-item:not(.lm-nav-context-item--disabled)'));
const currentIndex = items.indexOf(document.activeElement);
items[(currentIndex + 1) % items.length]?.focus();
}
_focusPrev() {
const items = Array.from(this.element.querySelectorAll('.lm-nav-context-item:not(.lm-nav-context-item--disabled)'));
const currentIndex = items.indexOf(document.activeElement);
items[(currentIndex - 1 + items.length) % items.length]?.focus();
}
}
// ========================================
// ModalLock
// ========================================
const ModalLock = {
_count: 0,
_originalOverflow: null,
_originalPaddingRight: null,
_getScrollbarWidth() {
if (document.documentElement.scrollHeight <= document.documentElement.clientHeight) return 0;
return window.innerWidth - document.documentElement.clientWidth;
},
acquire() {
this._count++;
if (this._count === 1) {
this._originalOverflow = document.body.style.overflow;
this._originalPaddingRight = document.body.style.paddingRight;
const scrollbarWidth = this._getScrollbarWidth();
document.body.style.overflow = 'hidden';
if (scrollbarWidth > 0) document.body.style.paddingRight = `${scrollbarWidth}px`;
}
},
release() {
this._count = Math.max(0, this._count - 1);
if (this._count === 0) {
document.body.style.overflow = this._originalOverflow || '';
document.body.style.paddingRight = this._originalPaddingRight || '';
this._originalOverflow = null;
this._originalPaddingRight = null;
}
},
};
// ========================================
// Dialog
// ========================================
class Dialog {
constructor(options = {}) {
this.options = { title: '', width: 480, closable: true, className: '', ...options };
this.element = null;
this.contentEl = null;
this.isOpen = false;
this._previousActiveElement = null;
this._boundKeyHandler = this._onKeyDown.bind(this);
}
open() {
if (this.isOpen) return this;
this._previousActiveElement = document.activeElement;
this._build();
const navContainer = document.getElementById(CONFIG.CONTAINER_ID);
(navContainer || document.body).appendChild(this.element);
ModalLock.acquire();
requestAnimationFrame(() => this.element.classList.add('lm-nav-dialog--visible'));
this.isOpen = true;
document.addEventListener('keydown', this._boundKeyHandler);
const focusTarget = this.element.querySelector('.lm-nav-dialog-close') ||
this.element.querySelector('button') ||
this.element.querySelector('[tabindex="0"]');
if (focusTarget) setTimeout(() => focusTarget.focus(), 80);
return this;
}
close() {
if (!this.isOpen || !this.element) return;
this.element.classList.remove('lm-nav-dialog--visible');
const cleanup = () => {
if (this.element?.parentNode) this.element.remove();
this.element = null;
this.contentEl = null;
this.isOpen = false;
ModalLock.release();
if (this._previousActiveElement?.focus) {
try { this._previousActiveElement.focus(); } catch (e) {}
}
this._previousActiveElement = null;
};
this.element.addEventListener('transitionend', cleanup, { once: true });
setTimeout(cleanup, 300);
document.removeEventListener('keydown', this._boundKeyHandler);
}
setContent(content) {
if (!this.contentEl) return this;
if (typeof content === 'string') this.contentEl.innerHTML = content;
else if (content instanceof Element) {
this.contentEl.innerHTML = '';
this.contentEl.appendChild(content);
}
return this;
}
updateTitle(title) {
const titleEl = this.element?.querySelector('.lm-nav-dialog-title');
if (titleEl) titleEl.textContent = title;
}
_build() {
this.element = document.createElement('div');
this.element.className = 'lm-nav-dialog-overlay';
if (this.options.className) this.element.classList.add(this.options.className);
this.element.setAttribute('role', 'dialog');
this.element.setAttribute('aria-modal', 'true');
this.element.setAttribute('aria-labelledby', 'lm-nav-dialog-title');
this.element.addEventListener('click', (e) => {
if (e.target === this.element && this.options.closable) this.close();
});
const dialog = document.createElement('div');
dialog.className = 'lm-nav-dialog';
dialog.style.maxWidth = `${this.options.width}px`;
dialog.addEventListener('click', (e) => e.stopPropagation());
const header = document.createElement('div');
header.className = 'lm-nav-dialog-header';
const title = document.createElement('h2');
title.id = 'lm-nav-dialog-title';
title.className = 'lm-nav-dialog-title';
title.textContent = this.options.title;
header.appendChild(title);
if (this.options.closable) {
const closeBtn = document.createElement('button');
closeBtn.className = 'lm-nav-dialog-close';
closeBtn.setAttribute('aria-label', '關閉');
closeBtn.appendChild(Icons.close('md'));
closeBtn.addEventListener('click', () => this.close());
header.appendChild(closeBtn);
}
dialog.appendChild(header);
this.contentEl = document.createElement('div');
this.contentEl.className = 'lm-nav-dialog-content';
dialog.appendChild(this.contentEl);
this.element.appendChild(dialog);
}
_onKeyDown(e) {
if (e.key === 'Escape' && this.options.closable) {
this.close();
e.preventDefault();
return;
}
if (e.key === 'Tab') this._handleTabKey(e);
}
_handleTabKey(e) {
const focusable = this.element.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (focusable.length === 0) return;
const first = focusable[0], last = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) { last.focus(); e.preventDefault(); }
} else {
if (document.activeElement === last) { first.focus(); e.preventDefault(); }
}
}
}
// ========================================
// Message Class
// ========================================
class Message {
constructor(element, type, text, index, meta = {}) {
this.element = element;
this.type = type;
this.text = text;
this.index = index;
this.side = meta?.side || null;
this._contentHash = this._hashText(this.text);
this.id = this._generateId();
this._legacyId = this._generateLegacyId();
// 預計算長度分類和顯示寬度因子
this._lengthCategory = this._calculateLengthCategory();
this._widthFactor = this._calculateWidthFactor();
}
_generateId() {
return `${this.type}-${this.index}-${this._contentHash}`;
}
_generateLegacyId() {
const textHash = this.text.substring(0, 50).replace(/\s+/g, '').substring(0, 20);
return `${this.type}-${this.index}-${textHash}`;
}
_hashText(text) {
return utils.hashText(text, 300);
}
getTextFingerprint() {
return this.text.substring(0, 80).replace(/\s+/g, ' ').trim().toLowerCase();
}
matchesId(id) {
return id && (this.id === id || this._legacyId === id);
}
isValid() {
if (!this.element || !document.body.contains(this.element)) return false;
const rect = this.element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}
getPreview(maxLength = CONFIG.PREVIEW_LENGTH) {
return utils.truncate(this.text.replace(/\s+/g, ' ').trim(), maxLength);
}
_calculateLengthCategory() {
const len = this.text.length;
if (len < 50) return 'tiny';
if (len < CONFIG.MSG_LENGTH_THRESHOLDS.SHORT) return 'short';
if (len < CONFIG.MSG_LENGTH_THRESHOLDS.MEDIUM) return 'medium';
if (len < CONFIG.MSG_LENGTH_THRESHOLDS.LONG) return 'long';
return 'very-long';
}
getLengthCategory() {
return this._lengthCategory;
}
// 計算寬度因子 (0.0 ~ 1.0),用於指示條視覺化
_calculateWidthFactor() {
const len = this.text.length;
const maxLen = CONFIG.MSG_LENGTH_THRESHOLDS.LONG;
return Math.min(1, Math.sqrt(len / maxLen)); // 使用平方根使分佈更均勻
}
getWidthFactor() {
return this._widthFactor;
}
}
// ========================================
// HistoryLoader
// ========================================
class HistoryLoader {
constructor(app) {
this.app = app;
this.isLoading = false;
this.shouldStop = false;
}
async load() {
if (this.isLoading) { this.stop(); return; }
this.isLoading = true;
this.shouldStop = false;
try {
const container = this._findScrollContainer();
if (!container) {
toast.error('找不到滾動容器');
this._finishLoading();
return;
}
const result = await this._run(container);
if (result.stopped) toast.info('已停止載入');
else if (result.newCount > 0) toast.success(`載入了 ${result.newCount} 條新消息`);
else toast.success('已載入全部歷史');
} catch (e) {
log('History load error:', e);
toast.error('載入時發生錯誤');
}
this._finishLoading();
}
stop() { this.shouldStop = true; }
async _run(container) {
const originalScroll = container.scrollTop;
const initialCount = this.app.messages.length;
let lastCount = initialCount, noChangeCount = 0;
const maxNoChange = CONFIG.HISTORY_LOAD_MAX_NO_CHANGE;
while (!this.shouldStop && noChangeCount < maxNoChange) {
container.scrollTop = Math.min(100, container.scrollHeight * 0.05);
await this._delay(50);
container.scrollTop = 0;
container.dispatchEvent(new Event('scroll', { bubbles: true }));
await this._delay(noChangeCount === 0 ? CONFIG.HISTORY_LOAD_DELAY_INITIAL : CONFIG.HISTORY_LOAD_DELAY_RETRY);
this.app.updateMessages();
const currentCount = this.app.messages.length;
if (currentCount > lastCount) {
lastCount = currentCount;
noChangeCount = 0;
} else {
noChangeCount++;
}
}
container.scrollTop = originalScroll;
return { stopped: this.shouldStop, newCount: lastCount - initialCount };
}
_findScrollContainer() {
const radix = document.querySelector('div[data-radix-scroll-area-viewport]');
if (radix?.scrollHeight > radix?.clientHeight) return radix;
const selectors = [
'main [class*="overflow-y-auto"]',
'main [class*="overflow-auto"]',
'main'
];
for (const sel of selectors) {
const el = document.querySelector(sel);
if (el?.scrollHeight > el?.clientHeight) return el;
}
return document.documentElement;
}
_finishLoading() {
this.isLoading = false;
this.shouldStop = false;
}
_delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
// ========================================
// KeyboardNav
// ========================================
class KeyboardNav {
constructor(app) {
this.app = app;
this.enabled = true;
this._boundHandler = this._onKeyDown.bind(this);
}
init() { document.addEventListener('keydown', this._boundHandler); }
destroy() { document.removeEventListener('keydown', this._boundHandler); }
setEnabled(enabled) { this.enabled = enabled; }
_onKeyDown(e) {
if (e.key === 'Escape') {
// 如果面板是 pinned 狀態,且焦點不在面板內,則不關閉面板
const panel = document.getElementById(CONFIG.PANEL_ID);
const isPinned = this.app.panelState === 'pinned';
const focusInPanel = panel && panel.contains(document.activeElement);
if (isPinned && !focusInPanel) {
// 焦點不在 pinned 面板內,Esc 不應關閉面板
// 但仍嘗試關閉其他浮層(菜單、對話框等)
if (this.app.contextMenu?.isVisible) {
this.app.contextMenu.hide();
e.preventDefault();
return;
}
if (this.app.settingsDialog?.isOpen) {
this.app._closeSettingsDialog();
e.preventDefault();
return;
}
if (this.app.favManagerDialog?.isOpen) {
this.app._closeFavManagerDialog();
e.preventDefault();
return;
}
// 沒有其他浮層需要關閉,不處理此 Esc
return;
}
// 其他情況:正常關閉邏輯
if (this.app.closeAnyOpenPanel()) e.preventDefault();
return;
}
const all = Keybindings.getAll();
const bindings = {
...all,
togglePanel: all.togglePanel || DEFAULT_KEYBINDINGS.togglePanel,
toggleSettings: all.toggleSettings || DEFAULT_KEYBINDINGS.toggleSettings,
toggleFavManager: all.toggleFavManager || DEFAULT_KEYBINDINGS.toggleFavManager
};
if (Keybindings.matchesEvent(bindings.togglePanel, e)) {
e.preventDefault(); e.stopPropagation();
this.app.togglePanel();
return;
}
if (Keybindings.matchesEvent(bindings.toggleSettings, e)) {
e.preventDefault(); e.stopPropagation();
this.app.toggleSettings();
return;
}
if (Keybindings.matchesEvent(bindings.toggleFavManager, e)) {
e.preventDefault(); e.stopPropagation();
this.app.toggleFavoriteManager();
return;
}
if (this._isInputFocused()) return;
if (this.app.isPanelOpen) {
const settings = Settings.getAll();
if (settings.paginatePanel) {
if (e.key === 'PageUp') { e.preventDefault(); this.app._goToPage(this.app.currentPage - 1); return; }
if (e.key === 'PageDown') { e.preventDefault(); this.app._goToPage(this.app.currentPage + 1); return; }
if (e.key === 'Home' && !e.altKey && !e.ctrlKey) { e.preventDefault(); this.app._goToPage(1); return; }
if (e.key === 'End' && !e.altKey && !e.ctrlKey) { e.preventDefault(); this.app._goToPage('last'); return; }
}
}
if (!this.enabled) return;
if (Keybindings.matchesEvent(bindings.navigateUp, e)) { e.preventDefault(); this.app.navigate(-1); return; }
if (Keybindings.matchesEvent(bindings.navigateDown, e)) { e.preventDefault(); this.app.navigate(1); return; }
if (Keybindings.matchesEvent(bindings.navigateFirst, e)) { e.preventDefault(); this.app.navigate('first'); return; }
if (Keybindings.matchesEvent(bindings.navigateLast, e)) { e.preventDefault(); this.app.navigate('last'); return; }
if (Keybindings.matchesEvent(bindings.toggleOrder, e)) { e.preventDefault(); this.app.toggleOrder(); return; }
if (Keybindings.matchesEvent(bindings.loadHistory, e)) { e.preventDefault(); this.app.loadHistory(); return; }
if (Keybindings.matchesEvent(bindings.toggleView, e)) { e.preventDefault(); this.app.togglePanelView(); return; }
if (Keybindings.matchesEvent(bindings.toggleIndicatorEdge, e)) { e.preventDefault(); this.app.toggleIndicatorEdge(); return; }
if (Keybindings.matchesEvent(bindings.togglePanelPin, e)) { e.preventDefault(); this.app.togglePanelPin(); return; }
if (Keybindings.matchesEvent(bindings.toggleAIMessages, e)) { e.preventDefault(); this.app.toggleAIMessages(); return; }
if (Keybindings.matchesEvent(bindings.cycleDisplayMode, e)) { e.preventDefault(); this.app.cycleDisplayMode(); return; }
if (Keybindings.matchesEvent(bindings.scrollToBottom, e)) { e.preventDefault(); this.app._scrollToBottom(); return; }
}
_isInputFocused() {
const el = document.activeElement;
if (!el) return false;
const tag = el.tagName?.toUpperCase();
return tag === 'INPUT' || tag === 'TEXTAREA' || el.isContentEditable || el.closest('.lm-nav-dialog');
}
}
// ========================================
// ChatNavigator (Main Application)
// ========================================
class ChatNavigator {
constructor() {
// === 數據狀態 ===
this.messages = [];
this.userMessages = [];
this.currentIndex = -1;
this.viewingIndex = -1;
this.currentPage = 1;
this.searchQuery = '';
this.panelState = 'hidden';
this.isSettingsOpen = false;
this.isFavManagerOpen = false;
this.lastUrl = location.href;
this.lastContentHash = '';
this.scrollParent = null;
this.initialized = false;
this.isScrolling = false;
this.isUpdating = false;
// === 子系統 ===
this.keyboard = new KeyboardNav(this);
this.contextMenu = new ContextMenu();
this.historyLoader = new HistoryLoader(this);
this.settingsDialog = null;
this.favManagerDialog = null;
this._toast = toast;
// === 拖曳狀態 ===
this._fabDrag = { active: false, startX: 0, startY: 0, startPos: null, moved: false };
this._panelDrag = { active: false, startX: 0, startY: 0, startPos: null, moved: false, justDragged: false };
this._indicatorDrag = { active: false, startX: 0, startY: 0, startPos: null, moved: false, justDragged: false, clickedLine: null };
this._panelResize = { active: false, direction: null, startX: 0, startY: 0, startWidth: 0, startHeight: 0 };
// === Hover 與自動隱藏 ===
this._hoverState = { showTimer: null, hideTimer: null, isOverIndicator: false, isOverPanel: false };
this._autoHideState = { fabTimer: null, indicatorTimer: null };
// === 監控器 ===
this._urlWatchTimer = null;
this._domWatchTimer = null;
this._popstateBound = false;
// === 快捷鍵捕獲 ===
this._keyCapture = { active: false, action: null, dialog: null, handler: null };
// === Fisheye 狀態 ===
this._fisheyeState = {
rafId: null,
idleTimer: null,
lastMouseY: null,
isActive: false,
linePositions: null,
linePositionsValid: false,
lastAppliedIndex: -1, // 避免重複計算
};
// === 收藏管理器狀態 ===
this._favManagerState = {
query: '',
selectionMode: false,
selected: new Set(),
visibleKeys: [],
itemByKey: new Map(),
suppressNextRemoveRerender: false,
suppressNextUndoRerender: false
};
this._favManagerPendingDeletes = new Map();
// === SPA 導航 ===
this._pendingNavigation = null;
// === 收藏匹配緩存 ===
this._favMatchCache = { key: '', ids: new Set(), favByMsgId: new Map() };
// === 事件處理器 ===
this._boundHandlers = {
scroll: utils.throttle(this._handleScroll.bind(this), CONFIG.SCROLL_THROTTLE),
mouseMove: this._handleMouseMove.bind(this),
mouseUp: this._handleMouseUp.bind(this),
documentClick: this._handleDocumentClick.bind(this),
proximity: utils.throttle(this._handleProximity.bind(this), 100),
resize: utils.debounce(this._handleResize.bind(this), 200),
};
this._debouncedUpdate = utils.debounce(() => this.updateMessages(), CONFIG.UPDATE_DEBOUNCE);
// === 監聽設定變更 ===
Settings.onChange((key, value, oldValue) => this._onSettingChange(key, value, oldValue));
Favorites.onChange((action, data) => this._onFavoriteChange(action, data));
Theme.onChange((isDark) => this._onThemeChange(isDark));
Keybindings.onChange(() => this._refreshKeybindingUI());
}
// ★ DOM 元素緩存 getter
get _fab() {
if (!this._fabEl || !document.body.contains(this._fabEl)) {
this._fabEl = document.getElementById(CONFIG.FAB_ID);
}
return this._fabEl;
}
get _indicator() {
if (!this._indicatorEl || !document.body.contains(this._indicatorEl)) {
this._indicatorEl = document.getElementById(CONFIG.INDICATOR_ID);
}
return this._indicatorEl;
}
get _panel() {
if (!this._panelEl || !document.body.contains(this._panelEl)) {
this._panelEl = document.getElementById(CONFIG.PANEL_ID);
}
return this._panelEl;
}
get _wrapper() {
if (!this._wrapperEl || !document.body.contains(this._wrapperEl)) {
this._wrapperEl = document.getElementById(CONFIG.WRAPPER_ID);
}
return this._wrapperEl;
}
get _container() {
if (!this._containerEl || !document.body.contains(this._containerEl)) {
this._containerEl = document.getElementById(CONFIG.CONTAINER_ID);
}
return this._containerEl;
}
// ★ 清除緩存的方法(在 UI 重建時調用)
_clearElementCache() {
this._fabEl = null;
this._indicatorEl = null;
this._panelEl = null;
this._wrapperEl = null;
this._containerEl = null;
}
// ========================================
// 初始化
// ========================================
async init() {
log('Initializing ChatNavigator v1.2.0...');
if (storage.hasGM4()) {
await storage.preloadGM4();
Settings.invalidateCache();
Keybindings.invalidateCache();
FabPosition.invalidateCache();
PanelPosition.invalidateCache();
PanelSize.invalidateCache();
IndicatorPosition.invalidateCache();
Favorites.invalidate();
}
await this._migrateSettingsIfNeeded();
Theme.detect();
this._injectStyles();
this.keyboard.init();
setTimeout(() => {
this.initialized = true;
this._buildUI();
this.updateMessages();
this._setupObservers();
this._setupDOMWatcher();
registerMenuCommands(this);
MessageTimestamps.cleanupExpired();
const fab = document.getElementById(CONFIG.FAB_ID);
if (fab) Onboarding.show(fab);
setTimeout(() => this.updateMessages(), 800);
setTimeout(() => this.updateMessages(), 2500);
log('Initialization complete');
}, CONFIG.INIT_DELAY);
}
async _migrateSettingsIfNeeded() {
try {
const existing = storage.get(CONFIG.STORAGE_KEY_SETTINGS, null);
if (existing != null) return;
for (const oldKey of ['lm_nav_settings_v7', 'lm_nav_settings_v6', 'lm_nav_settings_v5']) {
let oldVal = storage.get(oldKey, null);
if ((oldVal == null) && storage.hasGM4() && typeof GM?.getValue === 'function') {
try { const v = await GM.getValue(oldKey, null); if (v != null) oldVal = v; } catch (e) {}
}
if (oldVal != null) {
storage.set(CONFIG.STORAGE_KEY_SETTINGS, oldVal);
Settings.invalidateCache();
log(`Settings migrated from ${oldKey}`);
break;
}
}
} catch (e) {}
}
// ========================================
// DOM Watcher
// ========================================
_setupDOMWatcher() {
if (this._domWatchTimer) clearInterval(this._domWatchTimer);
this._domWatchTimer = setInterval(() => {
if (!this.initialized) return;
const container = document.getElementById(CONFIG.CONTAINER_ID);
const fab = document.getElementById(CONFIG.FAB_ID);
const indicator = document.getElementById(CONFIG.INDICATOR_ID);
if (!container || !document.body.contains(container) || !fab || !indicator) {
log('Critical DOM elements missing, rebuilding UI...');
this._rebuildUI();
}
}, CONFIG.DOM_WATCH_INTERVAL);
}
_rebuildUI() {
const preservedPanelState = this.panelState;
const preservedViewingIndex = this.viewingIndex;
document.querySelectorAll(`#${CONFIG.CONTAINER_ID}`).forEach(el => el.remove());
// ★ 清除元素緩存
this._clearElementCache();
this._injectStyles();
this._buildUI();
this._setupDOMWatcher();
this.updateMessages();
this.viewingIndex = preservedViewingIndex;
if (preservedPanelState !== 'hidden') {
setTimeout(() => this._showPanel(preservedPanelState), 100);
}
log('UI rebuilt successfully');
}
// ========================================
// 訊息查找
// ========================================
findMessages() {
const messages = [];
const ol = document.querySelector('ol.mt-8');
if (!ol) return messages;
const children = Array.from(ol.children);
let index = 0;
for (let i = children.length - 1; i >= 0; i--) {
const el = children[i];
if (el.tagName !== 'DIV' || el.classList.contains('h-0')) continue;
if (el.classList.contains('group') && el.classList.contains('flex')) {
const text = this._extractUserText(el);
if (text?.trim()) messages.push(new Message(el, 'user', text.trim(), index++));
continue;
}
const proseElements = el.querySelectorAll('.prose, .markdown');
if (proseElements.length === 0) continue;
if (proseElements.length >= 2) {
this._findSeparateAIContainers(el).forEach((containerInfo) => {
const text = this._extractAIText(containerInfo.element);
if (text?.trim()) messages.push(new Message(containerInfo.element, 'ai', text.trim(), index++, { side: containerInfo.side || null }));
});
} else {
const text = this._extractAIText(el);
if (text?.trim()) messages.push(new Message(el, 'ai', text.trim(), index++));
}
}
return messages;
}
_findSeparateAIContainers(parentEl) {
const containers = [];
const layoutContainer = parentEl.querySelector('[class*="grid"], [class*="flex"][class*="gap-"]');
if (layoutContainer) {
Array.from(layoutContainer.children).forEach((child, idx) => {
if (child.querySelector('.prose, .markdown')) {
containers.push({ element: child, side: idx === 0 ? 'A' : 'B' });
}
});
if (containers.length >= 2) return containers;
}
const proseElements = parentEl.querySelectorAll('.prose, .markdown');
const seen = new Set();
proseElements.forEach((prose) => {
let isNested = false;
for (const existing of seen) {
if (existing.contains(prose) || prose.contains(existing)) {
isNested = true;
break;
}
}
if (isNested) return;
seen.add(prose);
let container = prose, parent = prose.parentElement;
while (parent && parent !== parentEl) {
if (parent.querySelectorAll('.prose, .markdown').length === 1) {
container = parent;
parent = parent.parentElement;
} else break;
}
containers.push({ element: container, side: containers.length === 0 ? 'A' : 'B' });
});
return containers;
}
_extractUserText(el) {
if (!el) return '';
const prose = el.querySelector('.prose');
const clone = (prose || el).cloneNode(true);
this._cleanUIElements(clone);
return (clone.textContent || '').replace(/\n{4,}/g, '\n\n\n').trim();
}
_extractAIText(el) {
if (!el) return '';
const prose = el.querySelector('.prose, .markdown');
if (!prose) return '';
const clone = prose.cloneNode(true);
this._cleanUIElements(clone);
return (clone.textContent || '').replace(/\n{4,}/g, '\n\n\n').trim();
}
_cleanUIElements(el) {
if (!el) return;
el.querySelectorAll('button, svg, [aria-hidden="true"], [class*="toolbar"], [class*="actions"], [class*="controls"]').forEach(n => n.remove());
const uiTexts = ['Click to collapse code', 'Click to expand code', 'Click to collapse', 'Click to expand', 'Copy code', 'Copied!', 'Copy', 'Copied'];
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
const nodesToClean = [];
while (walker.nextNode()) {
let text = walker.currentNode.textContent || '', modified = false;
for (const uiText of uiTexts) {
if (text.includes(uiText)) {
text = text.split(uiText).join('');
modified = true;
}
}
if (modified) nodesToClean.push({ node: walker.currentNode, newText: text });
}
nodesToClean.forEach(({ node, newText }) => { node.textContent = newText; });
}
// ========================================
// 訊息更新
// ========================================
updateMessages() {
if (this.isUpdating || !this.initialized) return;
this.isUpdating = true;
try {
if (location.href !== this.lastUrl) this._resetState();
const msgs = this.findMessages();
const hash = msgs.map(m => (m.text || '').substring(0, 30)).join('|');
if (hash !== this.lastContentHash || msgs.length !== this.messages.length) {
this.lastContentHash = hash;
this.messages = msgs;
this.userMessages = msgs.filter(m => m.type === 'user');
this._fisheyeState.linePositionsValid = false;
const pageId = Favorites.getPageId();
msgs.forEach(msg => {
MessageTimestamps.recordIfNew(pageId, msg.id, msg.text);
});
this._renderAll();
if (this._pendingNavigation) {
this._completePendingNavigation();
}
}
} catch (e) { log('Update error:', e); }
this.isUpdating = false;
}
_resetState(options = {}) {
this.messages = [];
this.userMessages = [];
this.currentIndex = -1;
this.viewingIndex = -1;
this.currentPage = 1;
this.searchQuery = '';
this.lastContentHash = '';
this.scrollParent = null;
this.lastUrl = location.href;
this._fisheyeState.linePositionsValid = false;
this._fisheyeState.linePositions = null;
Favorites.invalidate();
if (!options.keepPanel) this._hidePanel();
this._renderIndicator();
if (options.keepPanel) {
this._renderList();
this._renderFavorites();
this._renderPagination();
this._updateFavBadge();
}
}
// ========================================
// 導航邏輯
// ========================================
navigate(direction) {
if (this.messages.length === 0) return;
let target;
switch (direction) {
case 'first': target = 0; break;
case 'last': target = this.messages.length - 1; break;
case -1: target = this.currentIndex === -1 ? 0 : Math.max(0, this.currentIndex - 1); break;
case 1: target = this.currentIndex === -1 ? 0 : Math.min(this.messages.length - 1, this.currentIndex + 1); break;
default: return;
}
this.navigateToIndex(target);
}
navigateToIndex(index) {
// ★ 增強防禦性檢查
if (typeof index !== 'number' || isNaN(index)) {
log('Warning: navigateToIndex received invalid index:', index);
return;
}
if (index < 0 || index >= this.messages.length) {
log('Warning: navigateToIndex index out of range:', index, 'total:', this.messages.length);
return;
}
const msg = this.messages[index];
if (!msg) {
log('Warning: navigateToIndex message not found at index:', index);
return;
}
if (!msg.isValid()) {
this.updateMessages();
return;
}
this.currentIndex = index;
this.viewingIndex = index;
this._scrollToMessage(msg);
this._updateIndicators();
}
_scrollToMessage(msg) {
const settings = Settings.getAll();
this.isScrolling = true;
msg.element.scrollIntoView({
behavior: settings.enableAnimation ? 'smooth' : 'auto',
block: settings.scrollPosition
});
if (!settings.enableAnimation) {
this._highlightMessage(msg);
this.isScrolling = false;
return;
}
const scrollParent = this._getScrollParent(msg.element);
let timer;
const onScrollEnd = () => {
clearTimeout(timer);
timer = setTimeout(() => {
this._highlightMessage(msg);
scrollParent.removeEventListener('scroll', onScrollEnd);
this.isScrolling = false;
}, 80);
};
scrollParent.addEventListener('scroll', onScrollEnd, { passive: true });
setTimeout(() => {
if (this.isScrolling) {
this._highlightMessage(msg);
scrollParent.removeEventListener('scroll', onScrollEnd);
this.isScrolling = false;
}
}, 550);
}
_highlightMessage(msg) {
const settings = Settings.getAll();
if (settings.enableAnimation) {
msg.element.classList.add(CONFIG.JIGGLE_CLASS);
setTimeout(() => msg.element.classList.remove(CONFIG.JIGGLE_CLASS), CONFIG.JIGGLE_DURATION);
} else {
const originalOutline = msg.element.style.outline;
const originalOffset = msg.element.style.outlineOffset;
msg.element.style.outline = '2px solid var(--lm-nav-accent, #3b82f6)';
msg.element.style.outlineOffset = '2px';
setTimeout(() => {
msg.element.style.outline = originalOutline;
msg.element.style.outlineOffset = originalOffset;
}, 600);
}
}
_getScrollParent(el) {
if (this.scrollParent && document.body.contains(this.scrollParent)) return this.scrollParent;
const radix = document.querySelector('div[data-radix-scroll-area-viewport]');
if (radix?.scrollHeight > radix?.clientHeight) { this.scrollParent = radix; return radix; }
let node = el?.parentElement;
while (node && node !== document.body) {
const style = getComputedStyle(node);
if ((style.overflowY === 'auto' || style.overflowY === 'scroll') && node.scrollHeight > node.clientHeight) {
this.scrollParent = node;
return node;
}
node = node.parentElement;
}
this.scrollParent = document.documentElement;
return document.documentElement;
}
_handleScroll() {
if (this.messages.length === 0 || this.isScrolling) return;
const viewportCenter = window.innerHeight * 0.3;
let closestIndex = -1, closestDistance = Infinity;
for (let i = 0; i < this.messages.length; i++) {
const msg = this.messages[i];
if (!msg.isValid()) continue;
const rect = msg.element.getBoundingClientRect();
if (rect.bottom < 0 || rect.top > window.innerHeight) continue;
const distance = Math.abs(rect.top - viewportCenter);
if (distance < closestDistance) { closestDistance = distance; closestIndex = i; }
}
if (closestIndex >= 0 && closestIndex !== this.viewingIndex) {
this.viewingIndex = closestIndex;
this._updateIndicators();
}
}
// ========================================
// 面板控制
// ========================================
get isPanelOpen() { return this.panelState !== 'hidden'; }
togglePanel() {
if (this.isPanelOpen) this._hidePanel();
else this._showPanel('open');
}
_showPanel(mode, anchor = null, forceAnchorPosition = false) {
const panel = document.getElementById(CONFIG.PANEL_ID);
if (!panel) return;
this._clearHoverTimers();
const rank = { hidden: 0, hover: 1, open: 2, pinned: 3 };
if ((rank[this.panelState] || 0) > 0 && (rank[mode] || 0) <= (rank[this.panelState] || 0)) return;
this.panelState = mode;
panel.classList.add('lm-nav-panel--open');
panel.classList.toggle('lm-nav-panel--pinned', mode === 'pinned');
this._updatePinButton();
this._positionPanel(anchor, mode === 'hover' || forceAnchorPosition);
this._applyPanelSize();
this._renderList();
this._renderFavorites();
this._renderPagination();
this._updateFavBadge();
this._syncDisplayModeButtons();
}
_hidePanel() {
const panel = document.getElementById(CONFIG.PANEL_ID);
if (!panel) return;
this._clearHoverTimers();
this.panelState = 'hidden';
panel.classList.remove('lm-nav-panel--open', 'lm-nav-panel--pinned');
}
_scheduleShowPanel(anchor) {
if (!Settings.get('hoverShowPanel') || this.panelState === 'open' || this.panelState === 'pinned') return;
this._clearHoverTimers();
this._hoverState.showTimer = setTimeout(() => this._showPanel('hover', anchor, true), CONFIG.HOVER_SHOW_DELAY);
}
_hasFocusInPanel() {
const panel = document.getElementById(CONFIG.PANEL_ID);
return panel?.contains(document.activeElement);
}
_scheduleHidePanel() {
if (this.panelState !== 'hover' || this._hasFocusInPanel()) return;
this._clearHoverTimers();
this._hoverState.hideTimer = setTimeout(() => {
if (this.panelState === 'hover' && !this._hoverState.isOverPanel && !this._hoverState.isOverIndicator && !this._hasFocusInPanel()) {
this._hidePanel();
}
}, CONFIG.HOVER_HIDE_DELAY);
}
_cancelHidePanel() {
if (this._hoverState.hideTimer) {
clearTimeout(this._hoverState.hideTimer);
this._hoverState.hideTimer = null;
}
}
_clearHoverTimers() {
if (this._hoverState.showTimer) { clearTimeout(this._hoverState.showTimer); this._hoverState.showTimer = null; }
if (this._hoverState.hideTimer) { clearTimeout(this._hoverState.hideTimer); this._hoverState.hideTimer = null; }
}
togglePanelPin() {
const panel = document.getElementById(CONFIG.PANEL_ID);
if (!this.isPanelOpen) {
this._showPanel('pinned');
toast.success('導航板已固定');
return;
}
if (this.panelState === 'pinned') {
this.panelState = 'open';
panel?.classList.remove('lm-nav-panel--pinned');
this._updatePinButton();
toast.info('導航板已取消固定');
} else {
this.panelState = 'pinned';
panel?.classList.add('lm-nav-panel--pinned');
this._updatePinButton();
toast.success('導航板已固定');
}
}
_updatePinButton() {
const btn = document.querySelector('.lm-nav-pin-btn');
if (!btn) return;
btn.innerHTML = '';
if (this.panelState === 'pinned') {
btn.appendChild(Icons.pinOff('md'));
btn.title = this._getButtonTooltip('取消固定', 'togglePanelPin');
btn.setAttribute('aria-pressed', 'true');
} else {
btn.appendChild(Icons.pin('md'));
btn.title = this._getButtonTooltip('固定導航板', 'togglePanelPin');
btn.setAttribute('aria-pressed', 'false');
}
}
_getButtonTooltip(baseLabel, actionKey) {
const binding = Keybindings.get(actionKey);
if (binding?.key) {
return `${baseLabel} (${Keybindings.formatBinding(binding)})`;
}
return baseLabel;
}
_positionPanel(anchor = null, forceAnchorPosition = false) {
const panel = document.getElementById(CONFIG.PANEL_ID);
if (!panel) return;
const size = PanelSize.load();
const panelWidth = size.width, panelHeight = Math.min(size.height, window.innerHeight - 100);
if (!forceAnchorPosition) {
const savedPos = PanelPosition.load();
if (savedPos.left !== null && savedPos.top !== null) {
panel.style.left = `${utils.clamp(savedPos.left, 10, window.innerWidth - panelWidth - 10)}px`;
panel.style.top = `${utils.clamp(savedPos.top, 10, window.innerHeight - panelHeight - 10)}px`;
return;
}
}
let left, top;
if (anchor) {
const pos = utils.calculatePanelPosition(anchor, panelWidth, panelHeight);
left = pos.left; top = pos.top;
} else {
const fab = document.getElementById(CONFIG.FAB_ID);
if (fab) {
const fabRect = fab.getBoundingClientRect();
left = fabRect.left - panelWidth - 12;
top = fabRect.bottom - panelHeight;
if (left < 10) left = fabRect.right + 12;
} else {
left = window.innerWidth - panelWidth - 24;
top = (window.innerHeight - panelHeight) / 2;
}
}
panel.style.left = `${utils.clamp(left, 10, window.innerWidth - panelWidth - 10)}px`;
panel.style.top = `${utils.clamp(top, 10, window.innerHeight - panelHeight - 10)}px`;
}
// ========================================
// 切換操作
// ========================================
togglePanelView() {
Settings.set('panelView', Settings.get('panelView') === 'messages' ? 'favorites' : 'messages');
}
toggleOrder() {
const current = Settings.get('reversedOrder');
Settings.set('reversedOrder', !current);
toast.info(current ? '已切換為正序' : '已切換為倒序');
}
loadHistory() { this.historyLoader.load(); }
toggleIndicatorEdge() {
const newPos = IndicatorPosition.toggleEdge();
this._applyIndicatorPosition();
toast.success(`指示條已移至${newPos.edge === 'left' ? '左' : '右'}側`);
}
// 同步顯示模式按鈕
_syncDisplayModeButtons() {
this._updateDisplayModeCycleButton();
}
// 根據當前視圖更新搜尋框 placeholder
_updateSearchPlaceholder() {
const input = document.querySelector('.lm-nav-search-input');
if (!input) return;
const view = Settings.get('panelView');
input.placeholder = view === 'favorites' ? '搜尋收藏...' : '搜尋消息...';
}
// 更新三態循環按鈕的圖標
_updateDisplayModeCycleButton() {
const btn = document.querySelector('.lm-nav-display-mode-cycle');
if (!btn) return;
const mode = Settings.get('messageDisplayMode') || 'user';
btn.dataset.mode = mode;
btn.innerHTML = '';
if (mode === 'user') {
btn.appendChild(Icons.user('sm'));
btn.title = '目前:僅用戶消息\n' + this._getButtonTooltip('點擊切換', 'cycleDisplayMode');
} else if (mode === 'ai') {
btn.appendChild(Icons.bot('sm'));
btn.title = '目前:僅 AI 回覆\n' + this._getButtonTooltip('點擊切換', 'cycleDisplayMode');
} else {
btn.appendChild(Icons.messageSquare('sm'));
btn.title = '目前:全部消息\n' + this._getButtonTooltip('點擊切換', 'cycleDisplayMode');
}
}
// 二態切換:user <-> both(符合「顯示/隱藏 AI」語義)
toggleAIMessages() {
const current = Settings.get('messageDisplayMode') || 'user';
const next = (current === 'both') ? 'user' : 'both';
Settings.set('messageDisplayMode', next);
this.currentPage = 1;
this._renderList();
this._renderFavorites();
this._renderPagination();
this._updateFavBadge();
this._syncDisplayModeButtons();
toast.info(next === 'both' ? '已顯示 AI 回覆' : '已隱藏 AI 回覆');
}
// 三態循環:user → ai → both → user
cycleDisplayMode() {
const current = Settings.get('messageDisplayMode') || 'user';
const next = { 'user': 'ai', 'ai': 'both', 'both': 'user' }[current] || 'user';
Settings.set('messageDisplayMode', next);
this.currentPage = 1;
this._renderList();
this._renderFavorites();
this._renderPagination();
this._updateFavBadge();
this._syncDisplayModeButtons();
const labels = { 'user': '僅顯示用戶消息', 'ai': '僅顯示 AI 回覆', 'both': '顯示所有消息' };
toast.info(labels[next]);
}
toggleSettings() {
if (this.isSettingsOpen) this._closeSettingsDialog();
else this._openSettingsDialog();
}
toggleFavoriteManager() {
if (this.isFavManagerOpen) this._closeFavManagerDialog();
else this._openFavManagerDialog();
}
// ========================================
// Settings Dialog
// ========================================
_openSettingsDialog() {
if (this.isSettingsOpen) return;
this.isSettingsOpen = true;
const dlg = new Dialog({ title: '設定', width: 520, className: 'lm-nav-settings-dialog' });
const origClose = dlg.close.bind(dlg);
dlg.close = () => { origClose(); this.isSettingsOpen = false; };
this.settingsDialog = dlg;
dlg.open().setContent(this._buildSettingsContent());
}
_closeSettingsDialog() {
this.settingsDialog?.close();
this.isSettingsOpen = false;
}
// ========================================
// Favorites Manager Dialog
// ========================================
_openFavManagerDialog() {
if (this.isFavManagerOpen) return;
this.isFavManagerOpen = true;
this._favManagerState = {
query: '',
selectionMode: false,
selected: new Set(),
visibleKeys: [],
itemByKey: new Map(),
suppressNextRemoveRerender: false,
suppressNextUndoRerender: false
};
const dlg = new Dialog({ title: `收藏夾 (${Favorites.getTotalCount()})`, width: 620, className: 'lm-nav-fav-manager-dialog' });
const origClose = dlg.close.bind(dlg);
dlg.close = () => { origClose(); this.isFavManagerOpen = false; };
this.favManagerDialog = dlg;
dlg.open().setContent(this._buildFavManagerRoot());
this._renderFavManagerList();
}
_closeFavManagerDialog() {
// ★ 清理所有待處理的刪除操作
this._clearFavManagerPendingDeletes();
// ★ 重置收藏管理器狀態
this._favManagerState = {
query: '',
selectionMode: false,
selected: new Set(),
visibleKeys: [],
itemByKey: new Map(),
suppressNextRemoveRerender: false,
suppressNextUndoRerender: false
};
this.favManagerDialog?.close();
this.isFavManagerOpen = false;
}
_navigateToConversation(item) {
if (!item?.url) return;
if (utils.isSameOrigin(item.url, window.location.href)) {
this.favManagerDialog?.close();
this._pendingNavigation = { item: item, attempts: 0, maxAttempts: CONFIG.PENDING_NAV_MAX_ATTEMPTS };
const targetPath = new URL(item.url).pathname + new URL(item.url).search;
if (targetPath !== location.pathname + location.search) {
history.pushState(null, '', item.url);
window.dispatchEvent(new PopStateEvent('popstate', { state: null }));
this._waitForPageAndNavigate();
} else {
this._scrollToFavoriteItem(item);
}
} else {
window.location.href = item.url;
}
}
_waitForPageAndNavigate() {
if (!this._pendingNavigation) return;
const nav = this._pendingNavigation;
nav.attempts++;
this._resetState({ keepPanel: false });
setTimeout(() => {
this.updateMessages();
const msg = Favorites.findMatchingMessage(this.messages, nav.item);
if (msg?.isValid()) {
this._pendingNavigation = null;
setTimeout(() => this.navigateToIndex(msg.index), 100);
return;
}
if (nav.attempts < nav.maxAttempts) {
setTimeout(() => this._waitForPageAndNavigate(), CONFIG.PENDING_NAV_DELAY);
} else {
const url = nav.item?.url;
this._pendingNavigation = null;
if (url) {
toast.warning('SPA 跳轉未成功載入內容,將以完整重載開啟對話...');
setTimeout(() => window.location.assign(url), 250);
} else {
toast.error('跳轉失敗:無效的收藏 URL');
}
}
}, 350);
}
_completePendingNavigation() {
if (!this._pendingNavigation) return;
const nav = this._pendingNavigation;
const msg = Favorites.findMatchingMessage(this.messages, nav.item);
if (msg?.isValid()) {
this._pendingNavigation = null;
setTimeout(() => this.navigateToIndex(msg.index), 100);
}
}
_scrollToFavoriteItem(item) {
if (!item) return;
this.updateMessages();
const msg = Favorites.findMatchingMessage(this.messages, item);
if (msg?.isValid()) {
this.navigateToIndex(msg.index);
} else {
toast.info('此消息在當前頁面中找不到');
}
}
_buildFavManagerRoot() {
const root = document.createElement('div');
root.className = 'lm-nav-fav-manager';
const searchBar = document.createElement('div');
searchBar.className = 'lm-nav-fav-manager-search';
const icon = document.createElement('span');
icon.className = 'lm-nav-search-icon';
icon.appendChild(Icons.search('sm'));
const input = document.createElement('input');
input.type = 'text';
input.className = 'lm-nav-fav-manager-search-input';
input.placeholder = '搜尋收藏(內容/標題)...';
const clear = document.createElement('button');
clear.className = 'lm-nav-search-clear';
clear.setAttribute('aria-label', '清除搜尋');
clear.appendChild(Icons.close('xs'));
clear.style.display = 'none';
input.addEventListener('input', () => {
const q = (input.value || '').trim().toLowerCase();
clear.style.display = q ? 'flex' : 'none';
this._favManagerState.query = q;
this._favManagerState.selected.clear();
this._renderFavManagerList();
});
clear.addEventListener('click', () => {
input.value = '';
input.focus();
clear.style.display = 'none';
this._favManagerState.query = '';
this._favManagerState.selected.clear();
this._renderFavManagerList();
});
searchBar.appendChild(icon);
searchBar.appendChild(input);
searchBar.appendChild(clear);
const toolbar = document.createElement('div');
toolbar.className = 'lm-nav-fav-manager-toolbar';
const left = document.createElement('div');
left.className = 'lm-nav-fav-manager-toolbar-left';
const right = document.createElement('div');
right.className = 'lm-nav-fav-manager-toolbar-right';
const selectionInfo = document.createElement('span');
selectionInfo.className = 'lm-nav-fav-manager-selection-info';
left.appendChild(selectionInfo);
const createBtn = (title, icon, text, onClick, dangerClass = '') => {
const btn = document.createElement('button');
btn.className = `lm-nav-btn-sm ${dangerClass}`;
btn.title = title;
btn.appendChild(icon);
btn.appendChild(document.createTextNode(` ${text}`));
btn.addEventListener('click', onClick);
return btn;
};
right.appendChild(createBtn('批量選取', Icons.checkSquare('sm'), '選取', () => {
this._favManagerState.selectionMode = !this._favManagerState.selectionMode;
if (!this._favManagerState.selectionMode) this._favManagerState.selected.clear();
this._renderFavManagerList();
}));
right.appendChild(createBtn('全選', Icons.square('sm'), '全選', () => {
(this._favManagerState.visibleKeys || []).forEach(k => this._favManagerState.selected.add(k));
this._updateFavManagerToolbar();
this._syncFavManagerCheckboxes();
}));
right.appendChild(createBtn('清除選取', Icons.minusSquare('sm'), '清除', () => {
this._favManagerState.selected.clear();
this._updateFavManagerToolbar();
this._syncFavManagerCheckboxes();
}));
right.appendChild(createBtn('刪除選取', Icons.trash('sm'), '刪除選取', () => this._favManagerDeleteSelected(), 'lm-nav-btn-sm--danger'));
right.appendChild(createBtn('清空所有收藏', Icons.alertTriangle('sm'), '清空全部', () => this._favManagerClearAll(), 'lm-nav-btn-sm--danger'));
toolbar.appendChild(left);
toolbar.appendChild(right);
const list = document.createElement('div');
list.className = 'lm-nav-fav-manager-list';
root.appendChild(searchBar);
root.appendChild(toolbar);
root.appendChild(list);
return root;
}
_updateFavManagerToolbar() {
const info = document.querySelector('.lm-nav-fav-manager-selection-info');
if (info) info.textContent = this._favManagerState.selectionMode ? `已選取:${this._favManagerState.selected.size}` : '';
}
_syncFavManagerCheckboxes() {
document.querySelectorAll('.lm-nav-fav-manager-item').forEach(li => {
const key = li.dataset.key;
if (!key) return;
li.classList.toggle('is-selected', this._favManagerState.selected.has(key));
const cb = li.querySelector('.lm-nav-fav-manager-checkbox');
if (cb) {
cb.innerHTML = '';
cb.appendChild(this._favManagerState.selected.has(key) ? Icons.checkSquare('sm') : Icons.square('sm'));
}
});
}
_favManagerDeleteSelected() {
const selectedKeys = Array.from(this._favManagerState.selected);
if (selectedKeys.length === 0) { toast.info('尚未選取任何收藏'); return; }
if (!confirm(`確定要刪除已選取的 ${selectedKeys.length} 項收藏嗎?`)) return;
const payload = [], removedItemsForUndo = [];
selectedKeys.forEach((key) => {
const item = this._favManagerState.itemByKey.get(key);
if (item) {
payload.push({ pageId: item.pageId, id: item.id });
removedItemsForUndo.push(item);
}
});
Favorites.removeMultiple(payload);
this._favManagerState.selected.clear();
this._renderFavManagerList();
if (removedItemsForUndo.length <= CONFIG.UNDO_MAX_ITEMS) {
toast.withUndo(`已刪除 ${removedItemsForUndo.length} 項收藏`, () => {
Favorites.undoRemoveMultiple(removedItemsForUndo);
this._renderFavManagerList();
toast.success('已撤銷');
}, CONFIG.TOAST_UNDO_DURATION);
} else {
toast.warning(`已刪除 ${removedItemsForUndo.length} 項收藏(數量過多,未提供撤銷)`, 3500);
}
}
_favManagerClearAll() {
const total = Favorites.getTotalCount();
if (total === 0) { toast.info('收藏夾已是空的'); return; }
if (!confirm(`確定要「清空全部收藏」(共 ${total} 項)嗎?`)) return;
const removed = Favorites.clearAll();
this._favManagerState.selected.clear();
this._renderFavManagerList();
if (removed.length <= 200) {
toast.withUndo(`已清空全部收藏(${removed.length} 項)`, () => {
Favorites.undoRemoveMultiple(removed);
this._renderFavManagerList();
toast.success('已撤銷');
}, 5000);
} else {
toast.warning(`已清空全部收藏(${removed.length} 項,數量過多未提供撤銷)`, 3800);
}
}
_renderFavManagerList() {
// 收藏管理器列表渲染邏輯
const listContainer = document.querySelector('.lm-nav-fav-manager-list');
if (!listContainer) return;
const query = this._favManagerState.query || '';
const allFavs = Favorites.getAllPages();
listContainer.innerHTML = '';
this._favManagerState.visibleKeys = [];
this._favManagerState.itemByKey.clear();
const sections = [];
for (const [pageId, items] of Object.entries(allFavs)) {
if (!items?.length) continue;
let filtered = query ? items.filter(item => `${item.pageTitle || ''}\n${item.preview || ''}\n${item.text || ''}`.toLowerCase().includes(query)) : items;
if (filtered?.length) sections.push({ pageId, title: filtered[0]?.pageTitle || pageId, items: filtered });
}
const totalCount = Favorites.getTotalCount();
const matchedCount = sections.reduce((s, sec) => s + sec.items.length, 0);
this.favManagerDialog?.updateTitle(query ? `收藏夾:${matchedCount}/${totalCount}` : `收藏夾 (${totalCount})`);
if (sections.length === 0) {
listContainer.innerHTML = `<div class="lm-nav-empty-state" style="display:flex;position:relative;"><div class="lm-nav-empty-icon">${Icons.search('xl').outerHTML}</div><p>${query ? '沒有找到匹配的收藏' : '還沒有任何收藏'}</p></div>`;
this._updateFavManagerToolbar();
return;
}
sections.forEach(sec => {
const section = document.createElement('div');
section.className = 'lm-nav-fav-section';
section.dataset.pageId = sec.pageId;
const header = document.createElement('div');
header.className = 'lm-nav-fav-section-header';
const titleSpan = document.createElement('span');
titleSpan.textContent = sec.title;
const clearBtn = document.createElement('button');
clearBtn.className = 'lm-nav-btn-xs lm-nav-btn-xs--danger';
clearBtn.textContent = '清空本頁';
clearBtn.title = '清空此對話頁面的所有收藏';
clearBtn.addEventListener('click', () => {
if (!confirm(`確定要清空「${sec.title}」的收藏嗎?(共 ${sec.items.length} 項)`)) return;
const removed = Favorites.clearPage(sec.pageId);
this._favManagerState.selected.clear();
this._renderFavManagerList();
if (removed.length <= 200) {
toast.withUndo(`已清空本頁(${removed.length} 項)`, () => {
Favorites.undoRemoveMultiple(removed);
this._renderFavManagerList();
toast.success('已撤銷');
}, 4500);
} else {
toast.warning(`已清空本頁(${removed.length} 項,數量過多未提供撤銷)`, 3500);
}
});
header.appendChild(titleSpan);
header.appendChild(clearBtn);
const ul = document.createElement('ul');
ul.className = 'lm-nav-fav-section-list';
sec.items.forEach(item => {
const key = `${item.pageId}:${item.id}`;
this._favManagerState.visibleKeys.push(key);
this._favManagerState.itemByKey.set(key, item);
ul.appendChild(this._createFavManagerItem(item, key));
});
section.appendChild(header);
section.appendChild(ul);
listContainer.appendChild(section);
});
this._updateFavManagerToolbar();
this._syncFavManagerCheckboxes();
}
_createFavManagerItem(item, key) {
const li = document.createElement('li');
li.className = 'lm-nav-fav-manager-item';
li.dataset.key = key;
const checkbox = document.createElement('button');
checkbox.className = 'lm-nav-fav-manager-checkbox';
checkbox.type = 'button';
checkbox.title = '選取';
checkbox.style.display = this._favManagerState.selectionMode ? 'flex' : 'none';
checkbox.appendChild(this._favManagerState.selected.has(key) ? Icons.checkSquare('sm') : Icons.square('sm'));
checkbox.addEventListener('click', (e) => {
if (li.classList.contains('lm-nav-fav-manager-item--pending') || !this._favManagerState.selectionMode) return;
e.stopPropagation();
if (this._favManagerState.selected.has(key)) this._favManagerState.selected.delete(key);
else this._favManagerState.selected.add(key);
this._updateFavManagerToolbar();
this._syncFavManagerCheckboxes();
});
const icon = document.createElement('span');
icon.className = 'lm-nav-fav-manager-icon';
icon.appendChild((item.type || 'user') === 'user' ? Icons.user('sm') : Icons.bot('sm'));
const text = document.createElement('span');
text.className = 'lm-nav-fav-manager-text';
text.textContent = item.preview || utils.truncate(item.text, 60);
text.title = item.text;
// 創建時間元素(收藏時間)
const time = document.createElement('span');
time.className = 'lm-nav-fav-manager-time';
time.textContent = utils.formatRelativeTime(item.timestamp);
// ★ 新增:消息時間戳記(如果有)
const msgTimestamp = document.createElement('span');
msgTimestamp.className = 'lm-nav-fav-manager-msg-time';
const pageId = item.pageId || Favorites.getPageId();
const msgTime = MessageTimestamps.getTime(pageId, item.text);
if (msgTime) {
msgTimestamp.textContent = MessageTimestamps.formatTimestamp(msgTime);
msgTimestamp.title = '消息時間';
} else {
// 沒有消息時間時,顯示收藏時間
msgTimestamp.textContent = utils.formatCompactTime(item.timestamp);
msgTimestamp.title = '收藏時間';
}
const actions = document.createElement('div');
actions.className = 'lm-nav-fav-manager-actions';
const createActionBtn = (title, iconEl, onClick, dangerClass = '') => {
const btn = document.createElement('button');
btn.className = `lm-nav-icon-btn ${dangerClass}`;
btn.title = title;
// ★ 關鍵修復:預設不可聚焦,防止 Tab 到隱藏按鈕
btn.tabIndex = -1;
btn.setAttribute('aria-hidden', 'true');
btn.appendChild(iconEl);
btn.addEventListener('click', onClick);
return btn;
};
actions.appendChild(createActionBtn('插入到輸入框', Icons.cornerDownLeft('sm'), (e) => { e.stopPropagation(); if (this._insertToInput(item.text)) this.favManagerDialog?.close(); }));
actions.appendChild(createActionBtn('複製', Icons.copy('sm'), (e) => { e.stopPropagation(); this._copyText(item.text, e.currentTarget); }));
actions.appendChild(createActionBtn('前往對話', Icons.externalLink('sm'), (e) => { e.stopPropagation(); this._navigateToConversation(item); }));
actions.appendChild(createActionBtn('刪除', Icons.trash('sm'), (e) => { e.stopPropagation(); this._favManagerRemoveWithInlineUndo(item, key, li); }, 'lm-nav-icon-btn--danger'));
li.appendChild(checkbox);
li.appendChild(icon);
li.appendChild(text);
li.appendChild(time);
li.appendChild(msgTimestamp);
li.appendChild(actions);
li.addEventListener('click', () => {
if (li.classList.contains('lm-nav-fav-manager-item--pending') || !this._favManagerState.selectionMode) return;
if (this._favManagerState.selected.has(key)) this._favManagerState.selected.delete(key);
else this._favManagerState.selected.add(key);
this._updateFavManagerToolbar();
this._syncFavManagerCheckboxes();
});
// ★ 新增:焦點管理 - 讓按鈕只在需要時可聚焦
utils.setupAccessibleHover(li, '.lm-nav-fav-manager-actions button');
return li;
}
_clearFavManagerPendingDeletes() {
for (const s of this._favManagerPendingDeletes.values()) {
try { if (s.timer) clearTimeout(s.timer); } catch (e) {}
}
this._favManagerPendingDeletes.clear();
}
_favManagerRemoveWithInlineUndo(item, key, li) {
if (!item || !key || !li || !this.isFavManagerOpen || this._favManagerPendingDeletes.has(key)) return;
let originalIndex = -1;
const arr = Favorites.getAllPages()[item.pageId] || [];
originalIndex = arr.findIndex(x => x.id === item.id);
this._favManagerState.suppressNextRemoveRerender = true;
const removed = Favorites.removeFromPage(item.pageId, item.id);
if (!removed) { this._favManagerState.suppressNextRemoveRerender = false; return; }
li.classList.add('lm-nav-fav-manager-item--pending');
li.querySelectorAll('.lm-nav-fav-manager-actions .lm-nav-icon-btn').forEach(btn => {
if (!btn.classList.contains('lm-nav-icon-btn--danger')) {
btn.disabled = true;
btn.style.opacity = '0.45';
btn.style.pointerEvents = 'none';
}
});
const oldDel = li.querySelector('.lm-nav-icon-btn--danger');
if (oldDel) {
const undoBtn = document.createElement('button');
undoBtn.className = 'lm-nav-icon-btn lm-nav-fav-undo-btn';
undoBtn.title = '撤銷刪除';
undoBtn.appendChild(Icons.undo('sm'));
undoBtn.addEventListener('click', (e) => { e.stopPropagation(); this._favManagerUndoPendingDelete(key); });
oldDel.replaceWith(undoBtn);
}
const finalize = () => this._favManagerFinalizePendingDelete(key);
const state = { item: removed, li, originalIndex, paused: false, finalize, timer: setTimeout(finalize, CONFIG.INLINE_UNDO_DURATION) };
li.addEventListener('mouseenter', () => {
const s = this._favManagerPendingDeletes.get(key);
if (s && !s.paused) { s.paused = true; if (s.timer) clearTimeout(s.timer); s.timer = null; }
});
li.addEventListener('mouseleave', () => {
const s = this._favManagerPendingDeletes.get(key);
if (s?.paused) { s.paused = false; s.timer = setTimeout(s.finalize, CONFIG.INLINE_UNDO_PAUSE_DURATION); }
});
this._favManagerPendingDeletes.set(key, state);
}
_favManagerUndoPendingDelete(key) {
const s = this._favManagerPendingDeletes.get(key);
if (!s) return;
try { if (s.timer) clearTimeout(s.timer); } catch (e) {}
this._favManagerPendingDeletes.delete(key);
this._favManagerState.suppressNextUndoRerender = true;
Favorites.undoRemoveAt(s.item, s.originalIndex);
const li = s.li;
if (!li) return;
li.classList.remove('lm-nav-fav-manager-item--pending');
li.querySelectorAll('.lm-nav-fav-manager-actions .lm-nav-icon-btn').forEach(btn => {
btn.disabled = false;
btn.style.opacity = '';
btn.style.pointerEvents = '';
});
const undoBtn = li.querySelector('.lm-nav-fav-undo-btn');
if (undoBtn) {
const delBtn = document.createElement('button');
delBtn.className = 'lm-nav-icon-btn lm-nav-icon-btn--danger';
delBtn.title = '刪除';
delBtn.appendChild(Icons.trash('sm'));
delBtn.addEventListener('click', (e) => { e.stopPropagation(); this._favManagerRemoveWithInlineUndo(s.item, key, li); });
undoBtn.replaceWith(delBtn);
}
}
_favManagerFinalizePendingDelete(key) {
const s = this._favManagerPendingDeletes.get(key);
if (!s) return;
try { if (s.timer) clearTimeout(s.timer); } catch (e) {}
this._favManagerPendingDeletes.delete(key);
const li = s.li;
if (!li?.parentNode) return;
const section = li.closest('.lm-nav-fav-section');
const list = li.closest('.lm-nav-fav-section-list');
li.remove();
if (list?.children.length === 0) section?.remove();
if (!document.querySelector('.lm-nav-fav-manager-list .lm-nav-fav-manager-item')) {
this._renderFavManagerList?.();
}
}
// ========================================
// UI 構建
// ========================================
_buildUI() {
this._cleanup();
const settings = Settings.getAll();
const fabPos = FabPosition.load();
const container = document.createElement('div');
container.id = CONFIG.CONTAINER_ID;
container.dataset.theme = Theme.isDark ? 'dark' : 'light';
container.dataset.showFab = settings.showFab ? 'true' : 'false';
container.dataset.showIndicator = settings.showIndicator ? 'true' : 'false';
container.dataset.autoHideFab = settings.autoHideFab ? 'true' : 'false';
container.dataset.autoHideIndicator = settings.autoHideIndicator ? 'true' : 'false';
container.dataset.autoCollapseIndicator = settings.autoCollapseIndicator ? 'true' : 'false';
container.dataset.listCompact = settings.listItemCompact ? 'true' : 'false';
container.style.setProperty('--lm-nav-font-size', `${settings.fontSize}px`);
container.style.setProperty('--lm-nav-font-family', Settings.getFontFamily());
container.appendChild(this._createFab(fabPos));
container.appendChild(this._createIndicator());
container.appendChild(this._createPanel(settings));
document.body.appendChild(container);
this._bindEvents();
this._applySettings(settings);
toast._ensureContainer?.();
}
_cleanup() {
document.getElementById(CONFIG.CONTAINER_ID)?.remove();
document.removeEventListener('mousemove', this._boundHandlers.mouseMove);
document.removeEventListener('mouseup', this._boundHandlers.mouseUp);
document.removeEventListener('click', this._boundHandlers.documentClick, true);
document.removeEventListener('mousemove', this._boundHandlers.proximity);
window.removeEventListener('scroll', this._boundHandlers.scroll, true);
window.removeEventListener('resize', this._boundHandlers.resize);
// ★ 新增:斷開觀察器
this._disconnectObservers();
if (this._urlWatchTimer) { clearInterval(this._urlWatchTimer); this._urlWatchTimer = null; }
if (this._domWatchTimer) { clearInterval(this._domWatchTimer); this._domWatchTimer = null; }
this._clearAutoHideTimers();
this._clearHoverTimers();
this._clearFisheyeEffect();
}
_createFab(pos) {
const fab = document.createElement('button');
fab.id = CONFIG.FAB_ID;
fab.className = 'lm-nav-fab';
fab.title = `對話導航 (${Keybindings.formatBinding(Keybindings.get('togglePanel'))})`;
fab.setAttribute('aria-label', '對話導航浮動鈕');
fab.style.bottom = `${pos.bottom}px`;
fab.style.right = `${pos.right}px`;
fab.appendChild(Icons.chat('lg'));
return fab;
}
_createIndicator() {
const indicator = document.createElement('div');
indicator.id = CONFIG.INDICATOR_ID;
indicator.className = 'lm-nav-indicator';
indicator.title = '對話導航指示條';
indicator.setAttribute('aria-label', '對話導航指示條');
const wrapper = document.createElement('div');
wrapper.id = CONFIG.WRAPPER_ID;
wrapper.className = 'lm-nav-indicator-wrapper';
indicator.appendChild(wrapper);
const pos = IndicatorPosition.load();
indicator.dataset.edge = pos.edge;
Object.assign(indicator.style, IndicatorPosition.getStyles());
return indicator;
}
// ========================================
// 面板構建
// ========================================
_createPanel(settings) {
const panel = document.createElement('div');
panel.id = CONFIG.PANEL_ID;
panel.className = 'lm-nav-panel';
panel.setAttribute('role', 'dialog');
panel.setAttribute('aria-label', '對話導航板');
panel.appendChild(this._createPanelHeader(settings));
panel.appendChild(this._createSearchRow());
panel.appendChild(this._createPanelContent());
// Resize handle
const resizeHandle = document.createElement('div');
resizeHandle.className = 'lm-nav-resize-handle';
resizeHandle.dataset.direction = 'se';
panel.appendChild(resizeHandle);
return panel;
}
_createPanelHeader(settings) {
const header = document.createElement('div');
header.className = 'lm-nav-header';
// 左側:視圖切換 + 顯示模式循環按鈕
const left = document.createElement('div');
left.className = 'lm-nav-header-left';
// 視圖切換按鈕
const viewToggle = document.createElement('button');
viewToggle.className = 'lm-nav-view-toggle';
viewToggle.title = this._getButtonTooltip('切換視圖', 'toggleView');
viewToggle.setAttribute('aria-label', '切換消息/收藏');
const viewIcon = document.createElement('span');
viewIcon.className = 'lm-nav-view-icon';
viewIcon.appendChild(settings.panelView === 'messages' ? Icons.messageSquare('sm') : Icons.starOutline('sm'));
const viewLabel = document.createElement('span');
viewLabel.className = 'lm-nav-view-label';
viewLabel.textContent = settings.panelView === 'messages' ? '消息' : '收藏';
const viewArrow = document.createElement('span');
viewArrow.className = 'lm-nav-view-arrow';
viewArrow.appendChild(Icons.chevronDown('xs'));
viewToggle.appendChild(viewIcon);
viewToggle.appendChild(viewLabel);
viewToggle.appendChild(viewArrow);
const favBadge = document.createElement('span');
favBadge.className = 'lm-nav-fav-badge';
favBadge.style.display = 'none';
viewToggle.appendChild(favBadge);
left.appendChild(viewToggle);
// 新增:顯示模式三態循環按鈕
const displayModeBtn = document.createElement('button');
displayModeBtn.className = 'lm-nav-action-btn lm-nav-display-mode-cycle';
displayModeBtn.title = this._getButtonTooltip('循環切換顯示模式', 'cycleDisplayMode');
displayModeBtn.setAttribute('aria-label', '循環切換顯示模式');
const currentMode = settings.messageDisplayMode || 'user';
displayModeBtn.dataset.mode = currentMode;
// 根據當前模式顯示對應圖標
if (currentMode === 'user') {
displayModeBtn.appendChild(Icons.user('sm'));
} else if (currentMode === 'ai') {
displayModeBtn.appendChild(Icons.bot('sm'));
} else {
displayModeBtn.appendChild(Icons.messageSquare('sm'));
}
left.appendChild(displayModeBtn);
header.appendChild(left);
// 右側:操作按鈕
const right = document.createElement('div');
right.className = 'lm-nav-header-right';
const createBtn = (className, title, icon) => {
const btn = document.createElement('button');
btn.className = `lm-nav-action-btn ${className}`;
btn.title = title;
btn.setAttribute('aria-label', title.split('(')[0].trim());
btn.appendChild(icon);
return btn;
};
right.appendChild(createBtn('lm-nav-sort-btn', this._getButtonTooltip('切換排序', 'toggleOrder'),
settings.reversedOrder ? Icons.sortDesc('sm') : Icons.sortAsc('sm')));
right.appendChild(createBtn('lm-nav-load-btn', this._getButtonTooltip('載入更多歷史', 'loadHistory'), Icons.download('sm')));
right.appendChild(createBtn('lm-nav-manage-btn', this._getButtonTooltip('收藏夾管理', 'toggleFavManager'), Icons.folderOpen('sm')));
right.appendChild(createBtn('lm-nav-pin-btn', this._getButtonTooltip('固定導航板', 'togglePanelPin'), Icons.pin('sm')));
right.appendChild(createBtn('lm-nav-settings-btn', this._getButtonTooltip('設定', 'toggleSettings'), Icons.settings('sm')));
header.appendChild(right);
return header;
}
// 獨立的搜尋列
_createSearchRow() {
const row = document.createElement('div');
row.className = 'lm-nav-search-row';
const icon = document.createElement('span');
icon.className = 'lm-nav-search-icon';
icon.appendChild(Icons.search('sm'));
const input = document.createElement('input');
input.type = 'text';
input.className = 'lm-nav-search-input';
// placeholder 會在 _updateSearchPlaceholder 中動態設定
input.placeholder = '搜尋消息...';
input.setAttribute('aria-label', '搜尋');
const clear = document.createElement('button');
clear.className = 'lm-nav-search-clear';
clear.setAttribute('aria-label', '清除搜尋');
clear.appendChild(Icons.close('xs'));
clear.style.display = 'none';
// 新增:分頁控制(移至搜尋列)
const pagination = document.createElement('div');
pagination.className = 'lm-nav-pagination';
const prevBtn = document.createElement('button');
prevBtn.className = 'lm-nav-page-btn lm-nav-page-prev';
prevBtn.setAttribute('aria-label', '上一頁');
prevBtn.appendChild(Icons.chevronLeft('xs'));
const pageInfo = document.createElement('span');
pageInfo.className = 'lm-nav-page-info';
const nextBtn = document.createElement('button');
nextBtn.className = 'lm-nav-page-btn lm-nav-page-next';
nextBtn.setAttribute('aria-label', '下一頁');
nextBtn.appendChild(Icons.chevronRight('xs'));
pagination.appendChild(prevBtn);
pagination.appendChild(pageInfo);
pagination.appendChild(nextBtn);
// 新增:統計(移至搜尋列)
const stats = document.createElement('span');
stats.className = 'lm-nav-stats';
row.appendChild(icon);
row.appendChild(input);
row.appendChild(clear);
row.appendChild(pagination);
row.appendChild(stats);
return row;
}
_createPanelContent() {
const content = document.createElement('div');
content.className = 'lm-nav-content';
const messageList = document.createElement('ul');
messageList.className = 'lm-nav-list';
messageList.setAttribute('role', 'listbox');
messageList.setAttribute('aria-label', '消息列表');
content.appendChild(messageList);
const favoriteList = document.createElement('ul');
favoriteList.className = 'lm-nav-favorites-list';
favoriteList.setAttribute('role', 'listbox');
favoriteList.setAttribute('aria-label', '收藏列表');
content.appendChild(favoriteList);
const emptyState = document.createElement('div');
emptyState.className = 'lm-nav-empty-state';
content.appendChild(emptyState);
return content;
}
// ========================================
// 事件綁定
// ========================================
_bindEvents() {
const fab = document.getElementById(CONFIG.FAB_ID);
if (fab) {
fab.addEventListener('mousedown', this._onFabMouseDown.bind(this));
fab.addEventListener('contextmenu', this._onContextMenu.bind(this));
}
const indicator = document.getElementById(CONFIG.INDICATOR_ID);
if (indicator) {
indicator.addEventListener('mousedown', this._onIndicatorMouseDown.bind(this));
indicator.addEventListener('mouseenter', this._onIndicatorMouseEnter.bind(this));
indicator.addEventListener('mouseleave', this._onIndicatorMouseLeave.bind(this));
indicator.addEventListener('contextmenu', this._onContextMenu.bind(this));
indicator.addEventListener('click', this._onIndicatorClick.bind(this));
indicator.addEventListener('mousemove', this._onIndicatorMouseMove.bind(this));
indicator.addEventListener('keydown', (e) => {
const pulse = e.target.closest('.lm-nav-pulse');
if (!pulse) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (pulse.dataset.action === 'bottom') {
this._scrollToBottom();
return;
}
const idx = parseInt(pulse.dataset.index);
if (!isNaN(idx)) this.navigateToIndex(idx);
}
});
}
const panel = document.getElementById(CONFIG.PANEL_ID);
if (panel) {
panel.addEventListener('mousedown', this._onPanelMouseDown.bind(this));
panel.addEventListener('mouseenter', this._onPanelMouseEnter.bind(this));
panel.addEventListener('mouseleave', this._onPanelMouseLeave.bind(this));
panel.addEventListener('click', this._onPanelClick.bind(this));
panel.addEventListener('contextmenu', this._onPanelContextMenu.bind(this));
panel.addEventListener('focusin', this._onPanelFocusIn.bind(this));
}
panel?.querySelector('.lm-nav-resize-handle')?.addEventListener('mousedown', this._onPanelResizeStart.bind(this));
const searchInput = document.querySelector('.lm-nav-search-input');
const searchClear = document.querySelector('.lm-nav-search-clear');
if (searchInput) {
searchInput.addEventListener('input', this._onSearchInput.bind(this));
searchInput.addEventListener('keydown', (e) => { if (e.key === 'Escape') searchInput.blur(); });
}
if (searchClear) searchClear.addEventListener('click', this._onSearchClear.bind(this));
document.addEventListener('mousemove', this._boundHandlers.mouseMove);
document.addEventListener('mouseup', this._boundHandlers.mouseUp);
document.addEventListener('click', this._boundHandlers.documentClick, true);
window.addEventListener('scroll', this._boundHandlers.scroll, { capture: true, passive: true });
window.addEventListener('resize', this._boundHandlers.resize);
if (Settings.getAll().autoHideFab || Settings.getAll().autoHideIndicator) this._enableAutoHide();
this._setupUrlWatcher();
}
// FAB 拖曳相關方法
_onFabMouseDown(e) {
if (e.button !== 0) return;
const fab = document.getElementById(CONFIG.FAB_ID);
if (!fab) return;
this._fabDrag = {
active: true,
startX: e.clientX,
startY: e.clientY,
startPos: {
bottom: parseInt(fab.style.bottom) || DEFAULT_FAB_POSITION.bottom,
right: parseInt(fab.style.right) || DEFAULT_FAB_POSITION.right
},
moved: false
};
fab.classList.add('lm-nav-fab--dragging');
e.preventDefault();
}
_handleMouseMove(e) {
if (this._fabDrag.active) this._handleFabDrag(e);
else if (this._panelResize.active) this._handlePanelResize(e);
else if (this._panelDrag.active) this._handlePanelDrag(e);
else if (this._indicatorDrag.active) this._handleIndicatorDrag(e);
}
_handleMouseUp() {
if (this._fabDrag.active) this._endFabDrag();
if (this._panelResize.active) this._endPanelResize();
if (this._panelDrag.active) this._endPanelDrag();
if (this._indicatorDrag.active) this._endIndicatorDrag();
}
_handleFabDrag(e) {
const fab = document.getElementById(CONFIG.FAB_ID);
if (!fab) return;
const dx = this._fabDrag.startX - e.clientX, dy = this._fabDrag.startY - e.clientY;
if (!this._fabDrag.moved && Math.sqrt(dx * dx + dy * dy) > 5) this._fabDrag.moved = true;
if (this._fabDrag.moved) {
fab.style.right = `${utils.clamp(this._fabDrag.startPos.right + dx, 10, window.innerWidth - fab.offsetWidth - 10)}px`;
fab.style.bottom = `${utils.clamp(this._fabDrag.startPos.bottom + dy, 10, window.innerHeight - fab.offsetHeight - 10)}px`;
}
}
_endFabDrag() {
const fab = document.getElementById(CONFIG.FAB_ID);
if (fab) {
fab.classList.remove('lm-nav-fab--dragging');
if (this._fabDrag.moved) FabPosition.save({ right: parseInt(fab.style.right), bottom: parseInt(fab.style.bottom) });
else this.togglePanel();
}
this._fabDrag.active = false;
this._fabDrag.moved = false;
}
// 指示條拖曳相關方法
_onIndicatorMouseDown(e) {
if (e.button !== 0) return;
const indicator = document.getElementById(CONFIG.INDICATOR_ID);
if (!indicator) return;
this._indicatorDrag = {
active: true,
startX: e.clientX,
startY: e.clientY,
startPos: { ...IndicatorPosition.load() },
moved: false,
justDragged: false,
clickedLine: e.target.closest('.lm-nav-pulse')
};
indicator.classList.add('lm-nav-indicator--dragging');
e.preventDefault();
}
_onIndicatorMouseEnter() {
this._hoverState.isOverIndicator = true;
this._cancelHidePanel();
if (!this._indicatorDrag.active) {
this._scheduleShowPanel(document.getElementById(CONFIG.INDICATOR_ID));
}
}
_onIndicatorMouseLeave() {
this._hoverState.isOverIndicator = false;
if (this.panelState === 'hover') this._scheduleHidePanel();
if (this._hoverState.showTimer) {
clearTimeout(this._hoverState.showTimer);
this._hoverState.showTimer = null;
}
this._clearFisheyeEffect();
}
// Fisheye 效果的滑鼠移動處理
_onIndicatorMouseMove(e) {
if (this._indicatorDrag.active) return;
const wrapper = document.getElementById(CONFIG.WRAPPER_ID);
if (!wrapper) return;
const pulses = wrapper.querySelectorAll('.lm-nav-pulse:not(.lm-nav-pulse--bottom)');
if (pulses.length < 3) return;
if (this._fisheyeState.rafId) return;
this._fisheyeState.rafId = requestAnimationFrame(() => {
this._fisheyeState.rafId = null;
if (!this._fisheyeState.isActive) {
wrapper.classList.add('lm-nav-fisheye-active');
this._fisheyeState.isActive = true;
}
this._fisheyeState.lastMouseY = e.clientY;
this._applyFisheyeEffect(wrapper, e.clientY);
});
}
_onIndicatorClick(e) {
if (this._indicatorDrag.moved || this._indicatorDrag.justDragged) return;
const pulse = e.target.closest('.lm-nav-pulse');
if (!pulse) return;
// ★ 阻止事件冒泡和默認行為
e.preventDefault();
e.stopPropagation();
if (pulse.dataset.action === 'bottom') {
this._scrollToBottom();
return;
}
const index = parseInt(pulse.dataset.index);
if (!isNaN(index)) this.navigateToIndex(index);
}
_handleIndicatorDrag(e) {
const indicator = document.getElementById(CONFIG.INDICATOR_ID);
if (!indicator) return;
const dx = e.clientX - this._indicatorDrag.startX, dy = e.clientY - this._indicatorDrag.startY;
if (!this._indicatorDrag.moved && Math.sqrt(dx * dx + dy * dy) > 5) this._indicatorDrag.moved = true;
if (!this._indicatorDrag.moved) return;
const newTop = utils.clamp(this._indicatorDrag.startPos.top + dy, 20, window.innerHeight - indicator.offsetHeight - 20);
const newEdge = e.clientX < window.innerWidth / 2 ? 'left' : 'right';
indicator.style.top = `${newTop}px`;
indicator.dataset.edge = newEdge;
if (newEdge === 'left') { indicator.style.left = '4px'; indicator.style.right = 'auto'; }
else { indicator.style.right = '4px'; indicator.style.left = 'auto'; }
}
_endIndicatorDrag() {
const indicator = document.getElementById(CONFIG.INDICATOR_ID);
if (indicator) {
indicator.classList.remove('lm-nav-indicator--dragging');
if (this._indicatorDrag.moved) {
IndicatorPosition.save({
edge: indicator.style.left !== 'auto' ? 'left' : 'right',
top: parseInt(indicator.style.top) || 100
});
this._indicatorDrag.justDragged = true;
setTimeout(() => { this._indicatorDrag.justDragged = false; }, 220);
toast.success(`指示條已移至${indicator.style.left !== 'auto' ? '左' : '右'}側`);
}
}
this._indicatorDrag.active = false;
const wasMoved = this._indicatorDrag.moved;
setTimeout(() => { if (wasMoved) this._indicatorDrag.moved = false; }, 220);
this._indicatorDrag.clickedLine = null;
}
// 面板拖曳相關方法
_onPanelMouseDown(e) {
if (e.target.closest('button, input, textarea, a, .lm-nav-resize-handle') || e.button !== 0) return;
const panel = document.getElementById(CONFIG.PANEL_ID);
if (!panel) return;
const rect = panel.getBoundingClientRect();
this._panelDrag = {
active: true,
startX: e.clientX,
startY: e.clientY,
startPos: { left: rect.left, top: rect.top },
moved: false,
justDragged: false
};
panel.classList.add('lm-nav-panel--dragging');
e.preventDefault();
}
_onPanelMouseEnter() {
this._hoverState.isOverPanel = true;
this._cancelHidePanel();
}
_onPanelMouseLeave() {
this._hoverState.isOverPanel = false;
if (!this._hasFocusInPanel() && this.panelState === 'hover') this._scheduleHidePanel();
}
_onPanelContextMenu(e) {
if (e.target.closest('input, textarea')) return;
e.preventDefault();
e.stopPropagation();
this._onContextMenu(e);
}
_onPanelFocusIn() {
if (this.panelState === 'hover') {
this.panelState = 'open';
this._clearHoverTimers();
}
}
_handlePanelDrag(e) {
const panel = document.getElementById(CONFIG.PANEL_ID);
if (!panel) return;
const dx = e.clientX - this._panelDrag.startX, dy = e.clientY - this._panelDrag.startY;
if (!this._panelDrag.moved && Math.sqrt(dx * dx + dy * dy) > 5) this._panelDrag.moved = true;
if (!this._panelDrag.moved) return;
panel.style.left = `${utils.clamp(this._panelDrag.startPos.left + dx, 10, window.innerWidth - panel.offsetWidth - 10)}px`;
panel.style.top = `${utils.clamp(this._panelDrag.startPos.top + dy, 10, window.innerHeight - panel.offsetHeight - 10)}px`;
}
_endPanelDrag() {
const panel = document.getElementById(CONFIG.PANEL_ID);
if (panel) {
panel.classList.remove('lm-nav-panel--dragging');
if (this._panelDrag.moved) {
PanelPosition.save({ left: parseInt(panel.style.left), top: parseInt(panel.style.top) });
this._panelDrag.justDragged = true;
setTimeout(() => { this._panelDrag.justDragged = false; }, 220);
}
}
this._panelDrag.active = false;
const wasMoved = this._panelDrag.moved;
setTimeout(() => { if (wasMoved) this._panelDrag.moved = false; }, 220);
}
// 面板點擊事件處理
_onPanelClick(e) {
if (this._panelDrag.moved || this._panelDrag.justDragged) return;
const target = e.target.closest('button, li');
if (!target) return;
if (target.closest('.lm-nav-view-toggle')) { this.togglePanelView(); return; }
if (target.classList.contains('lm-nav-sort-btn')) { this.toggleOrder(); return; }
if (target.classList.contains('lm-nav-load-btn')) { this.loadHistory(); return; }
if (target.classList.contains('lm-nav-manage-btn')) { this.toggleFavoriteManager(); return; }
if (target.classList.contains('lm-nav-pin-btn')) { this.togglePanelPin(); return; }
if (target.classList.contains('lm-nav-settings-btn')) { this.toggleSettings(); return; }
if (target.classList.contains('lm-nav-page-prev')) { this._goToPage(this.currentPage - 1); return; }
if (target.classList.contains('lm-nav-page-next')) { this._goToPage(this.currentPage + 1); return; }
// 顯示模式三態循環按鈕
if (target.closest('.lm-nav-display-mode-cycle')) {
this.cycleDisplayMode();
return;
}
if (target.classList.contains('lm-nav-item')) {
const index = parseInt(target.dataset.index);
if (!isNaN(index)) this.navigateToIndex(index);
return;
}
if (target.classList.contains('lm-nav-star-btn')) {
e.stopPropagation();
const idx = parseInt(target.dataset.msgIndex);
const msg = (!isNaN(idx) ? this.messages.find(m => m.index === idx) : null) ||
(target.dataset.msgId ? this.messages.find(m => m.id === target.dataset.msgId) : null);
if (!msg) { toast.error('找不到對應消息'); return; }
this._toggleFavoriteForMessage(msg);
return;
}
if (target.classList.contains('lm-nav-copy-btn')) {
e.stopPropagation();
this._copyText(target.dataset.text, target);
return;
}
if (target.classList.contains('lm-nav-insert-btn')) {
e.stopPropagation();
this._insertToInput(target.dataset.text);
return;
}
}
// 面板 resize 相關方法
_onPanelResizeStart(e) {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
const panel = document.getElementById(CONFIG.PANEL_ID);
if (!panel) return;
const rect = panel.getBoundingClientRect();
this._panelResize = {
active: true,
direction: e.target.dataset.direction || 'se',
startX: e.clientX,
startY: e.clientY,
startWidth: rect.width,
startHeight: rect.height
};
panel.classList.add('lm-nav-panel--resizing');
document.body.style.cursor = 'nwse-resize';
}
_handlePanelResize(e) {
const panel = document.getElementById(CONFIG.PANEL_ID);
if (!panel) return;
panel.style.width = `${utils.clamp(this._panelResize.startWidth + e.clientX - this._panelResize.startX, 300, 680)}px`;
panel.style.maxHeight = `${utils.clamp(this._panelResize.startHeight + e.clientY - this._panelResize.startY, 280, 850)}px`;
}
_endPanelResize() {
const panel = document.getElementById(CONFIG.PANEL_ID);
if (panel) {
panel.classList.remove('lm-nav-panel--resizing');
PanelSize.save({ width: parseInt(panel.style.width), height: parseInt(panel.style.maxHeight) });
}
this._panelResize.active = false;
document.body.style.cursor = '';
}
_applyPanelSize() {
const panel = document.getElementById(CONFIG.PANEL_ID);
if (!panel) return;
const size = PanelSize.load();
panel.style.width = `${size.width}px`;
panel.style.maxHeight = `${size.height}px`;
}
// 搜尋相關方法
_onSearchInput(e) {
const value = e.target.value;
const clearBtn = document.querySelector('.lm-nav-search-clear');
if (clearBtn) clearBtn.style.display = value ? 'flex' : 'none';
clearTimeout(this._searchTimer);
this._searchTimer = setTimeout(() => {
this.searchQuery = value.toLowerCase().trim();
this.currentPage = 1;
if (Settings.get('panelView') === 'messages') this._renderList();
else this._renderFavorites();
this._renderPagination();
}, 160);
}
_onSearchClear() {
const input = document.querySelector('.lm-nav-search-input');
const clearBtn = document.querySelector('.lm-nav-search-clear');
if (input) { input.value = ''; input.focus(); }
if (clearBtn) clearBtn.style.display = 'none';
this.searchQuery = '';
this.currentPage = 1;
if (Settings.get('panelView') === 'messages') this._renderList();
else this._renderFavorites();
this._renderPagination();
}
// 右鍵菜單
_onContextMenu(e) {
e.preventDefault();
e.stopPropagation();
const settings = Settings.getAll();
const indicatorPos = IndicatorPosition.load();
const bindings = Keybindings.getAll();
const menuItems = [
{ icon: () => Icons.list('sm'), label: this.isPanelOpen ? '關閉導航板' : '打開導航板', shortcut: Keybindings.formatBinding(bindings.togglePanel || DEFAULT_KEYBINDINGS.togglePanel), action: () => this.togglePanel() },
{ icon: () => (this.panelState === 'pinned' ? Icons.pinOff('sm') : Icons.pin('sm')), label: this.panelState === 'pinned' ? '取消固定導航板' : '固定導航板', shortcut: Keybindings.formatBinding(bindings.togglePanelPin || DEFAULT_KEYBINDINGS.togglePanelPin), action: () => this.togglePanelPin() },
{ separator: true },
{ icon: () => (settings.reversedOrder ? Icons.sortAsc('sm') : Icons.sortDesc('sm')), label: settings.reversedOrder ? '正序排列' : '倒序排列', shortcut: Keybindings.formatBinding(bindings.toggleOrder || DEFAULT_KEYBINDINGS.toggleOrder), action: () => this.toggleOrder() },
{ icon: () => Icons.download('sm'), label: '載入更多歷史', shortcut: Keybindings.formatBinding(bindings.loadHistory || DEFAULT_KEYBINDINGS.loadHistory), action: () => this.loadHistory() },
{ separator: true },
{ icon: () => Icons.folderOpen('sm'), label: '收藏夾管理', shortcut: Keybindings.formatBinding(bindings.toggleFavManager || DEFAULT_KEYBINDINGS.toggleFavManager), action: () => this.toggleFavoriteManager() },
{ icon: () => Icons.arrowLeftRight('sm'), label: `指示條移至${indicatorPos.edge === 'right' ? '左' : '右'}側`, shortcut: Keybindings.formatBinding(bindings.toggleIndicatorEdge || DEFAULT_KEYBINDINGS.toggleIndicatorEdge), action: () => this.toggleIndicatorEdge() },
{ icon: () => Icons.minimize('sm'), label: settings.autoCollapseIndicator ? '指示條停止自動縮小' : '指示條自動縮小', action: () => { Settings.set('autoCollapseIndicator', !settings.autoCollapseIndicator); toast.success(settings.autoCollapseIndicator ? '指示條已停止自動縮小' : '指示條將自動縮小'); } },
{ separator: true },
{ icon: () => Icons.settings('sm'), label: '設定', shortcut: Keybindings.formatBinding(bindings.toggleSettings || DEFAULT_KEYBINDINGS.toggleSettings), action: () => this.toggleSettings() },
{ icon: () => Icons.mapPin('sm'), label: '重置所有位置', action: () => { this.resetAllPositions(); toast.success('位置已重置'); } },
];
this.contextMenu.show(e.clientX, e.clientY, menuItems);
}
_handleDocumentClick(e) {
if (this.panelState === 'hidden' || this.panelState === 'pinned') return;
const panel = document.getElementById(CONFIG.PANEL_ID);
const fab = document.getElementById(CONFIG.FAB_ID);
const indicator = document.getElementById(CONFIG.INDICATOR_ID);
if (panel?.contains(e.target) || fab?.contains(e.target) || indicator?.contains(e.target) ||
this.contextMenu.element?.contains(e.target) || e.target.closest('.lm-nav-dialog-overlay, .lm-nav-toast')) return;
this._hidePanel();
}
_scrollToBottom() {
// 優先使用已緩存的滾動容器,否則嘗試查找
let scrollParent = this.scrollParent;
if (!scrollParent || !document.body.contains(scrollParent)) {
// 嘗試從現有消息中獲取滾動容器
const firstMsg = this.messages[0];
if (firstMsg?.element) {
scrollParent = this._getScrollParent(firstMsg.element);
} else {
// 回退方案:直接查找常見的滾動容器
scrollParent = document.querySelector('div[data-radix-scroll-area-viewport]')
|| document.querySelector('main [class*="overflow-y-auto"]')
|| document.documentElement;
}
}
if (!scrollParent) {
toast.error('找不到滾動容器');
return;
}
scrollParent.scrollTo({
top: scrollParent.scrollHeight,
behavior: Settings.getAll().enableAnimation ? 'smooth' : 'auto'
});
toast.info('已跳到底部');
}
_handleResize() {
this._validatePositions();
this._fisheyeState.linePositionsValid = false;
this._renderIndicator();
this._renderPagination();
}
_validatePositions() {
const fab = document.getElementById(CONFIG.FAB_ID);
if (fab) {
const right = parseInt(fab.style.right) || 0, bottom = parseInt(fab.style.bottom) || 0;
const newRight = utils.clamp(right, 10, window.innerWidth - fab.offsetWidth - 10);
const newBottom = utils.clamp(bottom, 10, window.innerHeight - fab.offsetHeight - 10);
if (newRight !== right || newBottom !== bottom) {
fab.style.right = `${newRight}px`;
fab.style.bottom = `${newBottom}px`;
FabPosition.save({ right: newRight, bottom: newBottom });
}
}
const indicator = document.getElementById(CONFIG.INDICATOR_ID);
if (indicator) {
const pos = IndicatorPosition.load();
const newTop = utils.clamp(pos.top, 20, window.innerHeight - indicator.offsetHeight - 20);
if (newTop !== pos.top) {
IndicatorPosition.save({ ...pos, top: newTop });
this._applyIndicatorPosition();
}
}
if (this.isPanelOpen) {
const panel = document.getElementById(CONFIG.PANEL_ID);
if (panel) {
const left = parseInt(panel.style.left) || 0, top = parseInt(panel.style.top) || 0;
const newLeft = utils.clamp(left, 10, window.innerWidth - panel.offsetWidth - 10);
const newTop = utils.clamp(top, 10, window.innerHeight - panel.offsetHeight - 10);
if (newLeft !== left || newTop !== top) {
panel.style.left = `${newLeft}px`;
panel.style.top = `${newTop}px`;
PanelPosition.save({ left: newLeft, top: newTop });
}
}
}
}
// 自動隱藏相關方法
_enableAutoHide() { document.addEventListener('mousemove', this._boundHandlers.proximity); }
_disableAutoHide() {
document.removeEventListener('mousemove', this._boundHandlers.proximity);
const container = document.getElementById(CONFIG.CONTAINER_ID);
if (container) { container.classList.add('lm-nav-fab--near', 'lm-nav-indicator--near'); }
}
_handleProximity(e) {
const settings = Settings.getAll();
const fab = document.getElementById(CONFIG.FAB_ID);
const indicator = document.getElementById(CONFIG.INDICATOR_ID);
const panel = document.getElementById(CONFIG.PANEL_ID);
const th = CONFIG.PROXIMITY_THRESHOLD;
const near = (el) => {
if (!el) return false;
const rect = el.getBoundingClientRect();
return e.clientX >= rect.left - th && e.clientX <= rect.right + th &&
e.clientY >= rect.top - th && e.clientY <= rect.bottom + th;
};
const container = document.getElementById(CONFIG.CONTAINER_ID);
if (settings.autoHideFab && fab) {
if (near(fab)) {
container?.classList.add('lm-nav-fab--near');
this._clearAutoHideFabTimer();
} else if (!this._autoHideState.fabTimer) {
this._autoHideState.fabTimer = setTimeout(() => {
container?.classList.remove('lm-nav-fab--near');
this._autoHideState.fabTimer = null;
}, CONFIG.AUTO_HIDE_DELAY);
}
}
if (settings.autoHideIndicator && indicator) {
if (near(indicator) || (panel && panel.contains(e.target) && this.panelState !== 'hidden')) {
container?.classList.add('lm-nav-indicator--near');
this._clearAutoHideIndicatorTimer();
} else if (!this._autoHideState.indicatorTimer) {
this._autoHideState.indicatorTimer = setTimeout(() => {
container?.classList.remove('lm-nav-indicator--near');
this._autoHideState.indicatorTimer = null;
}, CONFIG.AUTO_HIDE_DELAY);
}
}
}
_clearAutoHideFabTimer() {
if (this._autoHideState.fabTimer) {
clearTimeout(this._autoHideState.fabTimer);
this._autoHideState.fabTimer = null;
}
}
_clearAutoHideIndicatorTimer() {
if (this._autoHideState.indicatorTimer) {
clearTimeout(this._autoHideState.indicatorTimer);
this._autoHideState.indicatorTimer = null;
}
}
_clearAutoHideTimers() {
this._clearAutoHideFabTimer();
this._clearAutoHideIndicatorTimer();
}
// URL watcher
_setupUrlWatcher() {
if (!this._popstateBound) {
window.addEventListener('popstate', () => this._onUrlChange());
this._popstateBound = true;
}
if (!window.__LM_NAV_HISTORY_PATCHED__) {
const self = this;
const origPushState = history.pushState, origReplaceState = history.replaceState;
history.pushState = function (...args) { origPushState.apply(history, args); self._onUrlChange(); };
history.replaceState = function (...args) { origReplaceState.apply(history, args); self._onUrlChange(); };
window.__LM_NAV_HISTORY_PATCHED__ = true;
}
if (this._urlWatchTimer) clearInterval(this._urlWatchTimer);
this._urlWatchTimer = setInterval(() => {
if (location.href !== this.lastUrl) this._onUrlChange();
}, 1000);
}
_onUrlChange() {
const keep = this.panelState === 'pinned' || this.panelState === 'open';
this._resetState({ keepPanel: keep });
[350, 900, 1800, 2600].forEach(t => setTimeout(() => this.updateMessages(), t));
setTimeout(() => {
if (!document.getElementById(CONFIG.FAB_ID)) {
this._rebuildUI();
} else {
this.updateMessages();
}
}, 350);
}
_setupObservers() {
// ★ 先斷開現有的觀察器(如果存在)
this._disconnectObservers();
// 消息觀察器
this._messageObserver = new MutationObserver((mutations) => {
if (mutations.some(m => m.addedNodes?.length > 0 || m.target?.closest?.('ol.mt-8, main'))) {
this._debouncedUpdate();
}
});
// ★ 優化:只觀察聊天容器,而非整個 body
const chatContainer = document.querySelector('ol.mt-8') || document.querySelector('main') || document.body;
this._messageObserver.observe(chatContainer, { childList: true, subtree: true });
// 主題觀察器
this._themeObserver = new MutationObserver(() => Theme.detect());
this._themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
if (document.body) {
this._themeObserver.observe(document.body, { attributes: true, attributeFilter: ['class'] });
}
// 容器觀察器
this._containerObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.removedNodes) {
if (node.id === CONFIG.CONTAINER_ID || node.querySelector?.(`#${CONFIG.CONTAINER_ID}`)) {
log('Container removed by framework, scheduling rebuild...');
setTimeout(() => this._rebuildUI(), 50);
return;
}
}
}
});
this._containerObserver.observe(document.body, { childList: true, subtree: true });
}
// ★ 新增方法:斷開所有觀察器
_disconnectObservers() {
this._messageObserver?.disconnect();
this._themeObserver?.disconnect();
this._containerObserver?.disconnect();
this._messageObserver = null;
this._themeObserver = null;
this._containerObserver = null;
}
// ========================================
// 設定變更處理
// ========================================
_onSettingChange(key, value, oldValue) {
switch (key) {
case 'showFab':
case 'showIndicator':
case 'autoHideFab':
case 'autoHideIndicator':
case 'autoCollapseIndicator':
this._applyVisibilitySettings();
break;
case 'fabOpacity':
case 'indicatorOpacity':
case 'fabScale':
case 'fontSize':
case 'fontFamily':
this._applyAppearance();
break;
case 'reversedOrder':
this._updateSortButton();
this._renderList();
this._renderFavorites();
this._renderPagination();
break;
case 'messageDisplayMode':
this.currentPage = 1;
this._renderList();
this._renderFavorites();
this._renderPagination();
this._updateFavBadge();
this._syncDisplayModeButtons();
break;
case 'showAIInIndicator':
this._fisheyeState.linePositionsValid = false;
this._renderIndicator();
break;
case 'timestampMode':
case 'timestampShowTime':
this._renderList();
this._renderFavorites();
break;
case 'paginatePanel':
this.currentPage = 1;
this._renderList();
this._renderFavorites();
this._renderPagination();
break;
case 'panelView':
this._applyPanelView(value);
break;
case 'indicatorShowLength':
case 'indicatorShowFavorites':
this._fisheyeState.linePositionsValid = false;
this._renderIndicator();
break;
// ★ 新增:列表項緊湊模式
case 'listItemCompact':
this._applyListCompact();
this._renderList();
this._renderFavorites();
break;
case 'reset':
case 'batch':
this._applySettings(Settings.getAll());
break;
}
}
_onFavoriteChange(action, data) {
this._invalidateFavMatchCache();
if (this.isPanelOpen) {
if (Settings.get('panelView') === 'favorites') this._renderFavorites();
if (Settings.get('panelView') === 'messages') this._renderList();
}
if (action === 'removeMultiple' || action === 'clearAll' || action === 'clearPage') this._renderList();
if (Settings.get('indicatorShowFavorites')) this._updateIndicatorFavoriteMarkers();
this._renderPagination();
this._updateFavBadge();
if (this.isFavManagerOpen) {
const suppressRemove = action === 'remove' && this._favManagerState.suppressNextRemoveRerender;
const suppressUndo = action === 'undo' && this._favManagerState.suppressNextUndoRerender;
if (suppressRemove || suppressUndo) {
this._favManagerState.suppressNextRemoveRerender = false;
this._favManagerState.suppressNextUndoRerender = false;
} else {
this._clearFavManagerPendingDeletes?.();
this._renderFavManagerList?.();
}
}
}
_onThemeChange(isDark) {
const container = document.getElementById(CONFIG.CONTAINER_ID);
if (container) container.dataset.theme = isDark ? 'dark' : 'light';
if (toast.container) toast.container.dataset.theme = isDark ? 'dark' : 'light';
}
_applySettings(settings) {
this._applyVisibilitySettings();
this._applyAppearance();
this._applyPanelView(settings.panelView);
this._applyIndicatorPosition();
this._updateSortButton();
this._syncDisplayModeButtons();
this._updateFavBadge();
this._applyListCompact();
}
_applyVisibilitySettings() {
const container = document.getElementById(CONFIG.CONTAINER_ID);
if (!container) return;
const settings = Settings.getAll();
container.dataset.showFab = settings.showFab ? 'true' : 'false';
container.dataset.showIndicator = settings.showIndicator ? 'true' : 'false';
container.dataset.autoHideFab = settings.autoHideFab ? 'true' : 'false';
container.dataset.autoHideIndicator = settings.autoHideIndicator ? 'true' : 'false';
container.dataset.autoCollapseIndicator = settings.autoCollapseIndicator ? 'true' : 'false';
if (settings.autoHideFab || settings.autoHideIndicator) this._enableAutoHide();
else this._disableAutoHide();
}
_applyAppearance() {
const container = document.getElementById(CONFIG.CONTAINER_ID);
if (!container) return;
const settings = Settings.getAll();
container.style.setProperty('--lm-nav-fab-opacity', String(settings.fabOpacity / 100));
container.style.setProperty('--lm-nav-indicator-opacity', String(settings.indicatorOpacity / 100));
container.style.setProperty('--lm-nav-fab-scale', String(settings.fabScale / 100));
container.style.setProperty('--lm-nav-font-size', `${settings.fontSize}px`);
container.style.setProperty('--lm-nav-font-family', Settings.getFontFamily());
}
_applyListCompact() {
const container = this._container || document.getElementById(CONFIG.CONTAINER_ID);
if (!container) return;
const enabled = !!Settings.get('listItemCompact');
container.dataset.listCompact = enabled ? 'true' : 'false';
}
_applyPanelView(view) {
const panel = document.getElementById(CONFIG.PANEL_ID);
if (!panel) return;
panel.dataset.view = view;
const viewIcon = document.querySelector('.lm-nav-view-icon');
const viewLabel = document.querySelector('.lm-nav-view-label');
if (viewIcon) {
viewIcon.innerHTML = '';
viewIcon.appendChild(view === 'messages' ? Icons.messageSquare('sm') : Icons.starOutline('sm'));
}
if (viewLabel) viewLabel.textContent = view === 'messages' ? '消息' : '收藏';
this.currentPage = 1;
if (view === 'messages') this._renderList();
else this._renderFavorites();
this._renderPagination();
this._updateSearchPlaceholder();
}
_applyIndicatorPosition() {
const indicator = document.getElementById(CONFIG.INDICATOR_ID);
if (!indicator) return;
const pos = IndicatorPosition.load();
indicator.dataset.edge = pos.edge;
Object.assign(indicator.style, IndicatorPosition.getStyles());
}
_updateSortButton() {
const btn = document.querySelector('.lm-nav-sort-btn');
if (!btn) return;
btn.innerHTML = '';
btn.appendChild(Settings.get('reversedOrder') ? Icons.sortDesc('sm') : Icons.sortAsc('sm'));
btn.title = this._getButtonTooltip('切換排序', 'toggleOrder');
}
// ========================================
// 收藏匹配緩存
// ========================================
_invalidateFavMatchCache() {
this._favMatchCache = { key: '', ids: new Set(), favByMsgId: new Map() };
}
_getFavoriteMatchState() {
const favs = Favorites.getForCurrentPage() || [];
const key = `${Favorites.getPageId()}|${this.lastContentHash}|${favs.map(f => f.id).join(',')}`;
if (this._favMatchCache?.key === key) return this._favMatchCache;
const ids = new Set();
const favByMsgId = new Map();
for (const fav of favs) {
const matched = Favorites.findMatchingMessage(this.messages, fav);
if (matched) {
ids.add(matched.id);
favByMsgId.set(matched.id, fav);
}
}
this._favMatchCache = { key, ids, favByMsgId };
return this._favMatchCache;
}
_isMessageFavorited(msg) {
if (!msg) return false;
return this._getFavoriteMatchState().ids.has(msg.id);
}
_toggleFavoriteForMessage(msg) {
if (!msg) return;
const state = this._getFavoriteMatchState();
const existingFav = state.favByMsgId.get(msg.id);
if (existingFav) {
Favorites.remove(existingFav.id);
toast.success('已取消收藏');
} else {
Favorites.add(msg.id, msg.text, msg.type || 'user', { index: msg.index });
toast.success('已收藏');
}
this._invalidateFavMatchCache();
}
_getFilteredMessages() {
const mode = Settings.get('messageDisplayMode') || 'user';
switch (mode) {
case 'user':
return this.messages.filter(m => m.type === 'user');
case 'ai':
return this.messages.filter(m => m.type === 'ai');
case 'both':
default:
// ★ 始終返回副本,避免意外修改原始數據
return [...this.messages];
}
}
_getFilteredFavoritesForView() {
const mode = Settings.get('messageDisplayMode') || 'user';
let favs = Favorites.getForCurrentPage();
if (mode === 'user') favs = favs.filter(f => (f.type || 'user') === 'user');
else if (mode === 'ai') favs = favs.filter(f => (f.type || 'user') === 'ai');
if (this.searchQuery) favs = favs.filter(f => (f.text || f.preview || '').toLowerCase().includes(this.searchQuery));
return favs;
}
_getFilteredFavoriteCountForBadge() {
const mode = Settings.get('messageDisplayMode') || 'user';
const favs = Favorites.getForCurrentPage();
if (mode === 'user') return favs.filter(f => (f.type || 'user') === 'user').length;
if (mode === 'ai') return favs.filter(f => (f.type || 'user') === 'ai').length;
return favs.length;
}
_updateFavBadge() {
const badge = document.querySelector('.lm-nav-fav-badge');
if (!badge) return;
const count = this._getFilteredFavoriteCountForBadge();
const currentView = Settings.get('panelView');
// 只在「消息」視圖時顯示徽章(提示使用者有收藏)
// 在「收藏」視圖時不顯示(已經在看收藏了,無需提示)
const shouldShow = count > 0 && currentView === 'messages';
badge.textContent = count > 0 ? count : '';
badge.style.display = shouldShow ? 'flex' : 'none';
}
// ========================================
// 渲染核心
// ========================================
_renderAll() {
this._renderIndicator();
this._renderList();
this._renderFavorites();
this._renderPagination();
this._updateFavBadge();
this._syncDisplayModeButtons();
}
// ========================================
// 指示條渲染 (脈衝設計)
// ========================================
_renderIndicator() {
const wrapper = document.getElementById(CONFIG.WRAPPER_ID);
if (!wrapper) return;
wrapper.innerHTML = '';
this._fisheyeState.linePositionsValid = false;
this._fisheyeState.linePositions = null;
this._fisheyeState.isActive = false;
wrapper.classList.remove('lm-nav-fisheye-active');
const settings = Settings.getAll();
let messagesToRender = settings.showAIInIndicator ? this.messages : this.messages.filter(m => m.type === 'user');
const total = messagesToRender.length;
if (total === 0) return;
// 計算可用高度與密度
const maxHeight = window.innerHeight * CONFIG.INDICATOR_MAX_HEIGHT_RATIO;
const { pulseHeight, pulseGap, isCrowded } = this._calculateIndicatorDensity(total, maxHeight);
wrapper.style.setProperty('--lm-pulse-h', `${pulseHeight}px`);
wrapper.style.setProperty('--lm-pulse-gap', `${pulseGap}px`);
wrapper.dataset.crowded = isCrowded ? 'true' : 'false';
// 收藏狀態
let favoritedMsgIds = null;
if (settings.indicatorShowFavorites) {
const state = this._getFavoriteMatchState();
favoritedMsgIds = state.ids;
}
// 渲染每個脈衝
messagesToRender.forEach((msg) => {
const pulse = document.createElement('div');
let cls = `lm-nav-pulse lm-nav-pulse--${msg.type}`;
if (msg.type === 'ai' && msg.side) {
cls += msg.side === 'A' ? ' lm-nav-pulse--ai-a' : ' lm-nav-pulse--ai-b';
}
pulse.className = cls;
pulse.dataset.index = String(msg.index);
pulse.dataset.type = msg.type;
// 計算並存儲寬度因子
const widthFactor = msg.getWidthFactor();
pulse.dataset.widthFactor = widthFactor.toFixed(3);
// ★ 永遠算出「該 pulse 在展開狀態時應該有的寬度」並寫到 CSS 變數
let targetWidth;
if (settings.indicatorShowLength) {
const minW = DESIGN_TOKENS.pulse.minWidth;
const maxW = DESIGN_TOKENS.pulse.maxWidth;
targetWidth = Math.round(minW + widthFactor * (maxW - minW));
} else {
targetWidth = DESIGN_TOKENS.pulse.standardWidth;
}
// 交給 CSS 使用(hover 展開時會用到)
pulse.style.setProperty('--lm-pulse-w', `${targetWidth}px`);
// ★ 非 autoCollapse 模式:也可以直接讓目前狀態就用這個寬度(不再靠 fallback)
if (!settings.autoCollapseIndicator) {
pulse.style.width = `${targetWidth}px`;
} else {
// autoCollapse 模式:讓 CSS 在非 hover 時變圓點,hover 時回到 var(--lm-pulse-w)
pulse.style.width = '';
}
// 收藏標記
if (settings.indicatorShowFavorites) {
pulse.dataset.favorite = (favoritedMsgIds?.has(msg.id)) ? 'true' : 'false';
}
const sideLabel = (msg.type === 'ai' && msg.side) ? ` (${msg.side})` : '';
pulse.title = `#${msg.index + 1} ${msg.type === 'user' ? '我' : 'AI'}${sideLabel}: ${msg.getPreview(40)}`;
pulse.setAttribute('role', 'button');
pulse.setAttribute('tabindex', '0');
pulse.setAttribute('aria-label', `跳到第 ${msg.index + 1} 條消息`);
if (msg.index === this.viewingIndex) pulse.classList.add('lm-nav-pulse--viewing');
if (msg.index === this.currentIndex) pulse.classList.add('lm-nav-pulse--current');
wrapper.appendChild(pulse);
});
// 底部跳轉按鈕
const bottomPulse = document.createElement('div');
bottomPulse.className = 'lm-nav-pulse lm-nav-pulse--bottom';
bottomPulse.dataset.action = 'bottom';
bottomPulse.title = '跳到頁面底部';
bottomPulse.setAttribute('role', 'button');
bottomPulse.setAttribute('tabindex', '0');
bottomPulse.setAttribute('aria-label', '跳到頁面底部');
wrapper.appendChild(bottomPulse);
// 自動縮小模式:綁定懸停恢復事件
// autoCollapseIndicator 的展開寬度由 CSS + --lm-pulse-w 處理,不需要事件綁定
this._syncIndicatorScroll();
}
// 計算指示條密度
_calculateIndicatorDensity(total, maxHeight) {
const { pulse } = DESIGN_TOKENS;
const standardStep = pulse.standardHeight + pulse.gap.standard;
const neededHeight = total * standardStep;
if (neededHeight <= maxHeight) {
return {
pulseHeight: pulse.standardHeight,
pulseGap: pulse.gap.standard,
isCrowded: false,
};
}
// 需要壓縮
const availablePerItem = maxHeight / total;
const compressionRatio = availablePerItem / standardStep;
// 漸進式壓縮:先壓縮間距,再壓縮高度
let pulseHeight = pulse.standardHeight;
let pulseGap = pulse.gap.standard;
if (compressionRatio > 0.6) {
// 輕度壓縮:只減少間距
pulseGap = Math.max(pulse.gap.compressed, availablePerItem - pulseHeight);
} else if (compressionRatio > 0.3) {
// 中度壓縮:減少間距並略微減少高度
pulseGap = pulse.gap.compressed;
pulseHeight = Math.max(pulse.minHeight + 1, availablePerItem - pulseGap);
} else {
// 重度壓縮:最小化
pulseGap = pulse.gap.minimum;
pulseHeight = Math.max(pulse.minHeight, availablePerItem - pulseGap);
}
return {
pulseHeight,
pulseGap,
isCrowded: true,
};
}
/**
* Dock-like Fisheye 效果
* 設計目標:
* 1. 焦點元素放大
* 2. 鄰近元素按距離遞減放大
* 3. 間距對稱擴展(上方向上推,下方向下推)
* 4. 整體效果以焦點為中心「呼吸」
*/
_applyFisheyeEffect(wrapper, mouseY) {
const pulses = Array.from(wrapper.querySelectorAll('.lm-nav-pulse:not(.lm-nav-pulse--bottom)'));
const n = pulses.length;
if (n < 2) return; // 至少需要 2 條才有意義
const wrapperRect = wrapper.getBoundingClientRect();
const relativeY = mouseY - wrapperRect.top + wrapper.scrollTop;
// 確保位置數據有效
if (!this._fisheyeState.linePositionsValid || !this._fisheyeState.linePositions) {
this._recalculateLinePositions(wrapper, pulses);
}
const positions = this._fisheyeState.linePositions;
if (!positions || positions.length !== n) {
this._recalculateLinePositions(wrapper, pulses);
}
// 找到最近的脈衝
let closestIndex = 0;
let minDist = Infinity;
for (let i = 0; i < n; i++) {
const dist = Math.abs(relativeY - positions[i].center);
if (dist < minDist) {
minDist = dist;
closestIndex = i;
}
}
// ★ 動態調整影響範圍:訊息少時縮小範圍
const baseInfluence = CONFIG.FISHEYE_INFLUENCE_COUNT;
const influenceCount = Math.min(baseInfluence, Math.max(1, Math.floor(n / 4)));
// 效果參數
const maxScaleX = CONFIG.FISHEYE_MAX_SCALE_X || 1.5;
const maxScaleY = CONFIG.FISHEYE_MAX_SCALE;
const maxGapBoost = CONFIG.FISHEYE_MAX_GAP_BOOST || 5;
const brightnessBoost = CONFIG.FISHEYE_BRIGHTNESS_BOOST;
// 獲取基礎間距
const baseGap = parseFloat(getComputedStyle(wrapper).getPropertyValue('--lm-pulse-gap')) ||
DESIGN_TOKENS.pulse.gap.standard;
// 性能優化:計算需要更新的範圍
const prevIndex = this._fisheyeState.lastAppliedIndex;
let updateStart, updateEnd;
if (prevIndex < 0) {
updateStart = 0;
updateEnd = n - 1;
} else {
updateStart = Math.max(0, Math.min(prevIndex, closestIndex) - influenceCount - 2);
updateEnd = Math.min(n - 1, Math.max(prevIndex, closestIndex) + influenceCount + 2);
}
// 應用效果
for (let i = updateStart; i <= updateEnd; i++) {
const distance = Math.abs(i - closestIndex);
const pulse = pulses[i];
if (distance <= influenceCount) {
// 在影響範圍內
const t = 1 - (distance / (influenceCount + 1));
const smoothT = utils.smoothstep(t);
// 雙軸縮放
const scaleX = 1 + (maxScaleX - 1) * smoothT;
const scaleY = 1 + (maxScaleY - 1) * smoothT;
// 亮度提升
const brightness = 1 + brightnessBoost * smoothT;
// ★ 對稱間距計算
const gapBoost = Math.round(maxGapBoost * smoothT);
// ★ 對稱位移:以焦點為中心向外推
// 焦點本身不移動,上方向上移,下方向下移
let translateY = 0;
if (i < closestIndex) {
// 上方的元素:計算它與焦點之間所有元素的累積間距增加
let cumulativeOffset = 0;
for (let j = i; j < closestIndex; j++) {
const jDist = Math.abs(j - closestIndex);
const jT = 1 - (jDist / (influenceCount + 1));
const jSmooth = utils.smoothstep(jT);
cumulativeOffset += maxGapBoost * jSmooth * 0.5;
}
translateY = -cumulativeOffset;
} else if (i > closestIndex) {
// 下方的元素:計算焦點與它之間所有元素的累積間距增加
let cumulativeOffset = 0;
for (let j = closestIndex; j < i; j++) {
const jDist = Math.abs(j - closestIndex);
const jT = 1 - (jDist / (influenceCount + 1));
const jSmooth = utils.smoothstep(jT);
cumulativeOffset += maxGapBoost * jSmooth * 0.5;
}
translateY = cumulativeOffset;
}
// 應用樣式
pulse.style.transform = `translateY(${translateY.toFixed(1)}px) scale(${scaleX.toFixed(3)}, ${scaleY.toFixed(3)})`;
pulse.style.filter = `brightness(${brightness.toFixed(3)})`;
pulse.style.zIndex = String(Math.round(smoothT * 10) + 1);
} else {
// 不在影響範圍:重置
pulse.style.transform = '';
pulse.style.filter = '';
pulse.style.zIndex = '';
pulse.style.transformOrigin = '';
}
}
this._fisheyeState.lastAppliedIndex = closestIndex;
}
// 輔助方法:重新計算脈衝位置
_recalculateLinePositions(wrapper, pulses) {
const gap = parseFloat(wrapper.style.getPropertyValue('--lm-pulse-gap')) ||
DESIGN_TOKENS.pulse.gap.standard;
let cumulative = 0;
this._fisheyeState.linePositions = pulses.map(pulse => {
const h = pulse.offsetHeight || 4;
const center = cumulative + h / 2;
cumulative += h + gap;
return { center, height: h };
});
this._fisheyeState.linePositionsValid = true;
}
_clearFisheyeEffect() {
if (this._fisheyeState.rafId) {
cancelAnimationFrame(this._fisheyeState.rafId);
this._fisheyeState.rafId = null;
}
if (this._fisheyeState.idleTimer) {
clearTimeout(this._fisheyeState.idleTimer);
this._fisheyeState.idleTimer = null;
}
const wrapper = document.getElementById(CONFIG.WRAPPER_ID);
if (!wrapper) return;
wrapper.classList.remove('lm-nav-fisheye-active');
this._fisheyeState.isActive = false;
this._fisheyeState.lastMouseY = null;
this._fisheyeState.lastAppliedIndex = -1; // ★ 新增:重置索引
wrapper.querySelectorAll('.lm-nav-pulse').forEach(pulse => {
pulse.style.transform = '';
pulse.style.filter = '';
pulse.style.zIndex = '';
pulse.style.marginBottom = ''; // ★ 新增:清除間距
pulse.style.transformOrigin = '';
});
}
_updateIndicatorFavoriteMarkers() {
if (!Settings.get('indicatorShowFavorites')) return;
const wrapper = document.getElementById(CONFIG.WRAPPER_ID);
if (!wrapper) return;
const state = this._getFavoriteMatchState();
wrapper.querySelectorAll('.lm-nav-pulse:not(.lm-nav-pulse--bottom)').forEach(pulse => {
const idx = parseInt(pulse.dataset.index);
if (isNaN(idx)) return;
const msg = this.messages.find(m => m.index === idx);
if (msg) pulse.dataset.favorite = state.ids.has(msg.id) ? 'true' : 'false';
});
}
_syncIndicatorScroll() {
const wrapper = document.getElementById(CONFIG.WRAPPER_ID);
if (!wrapper || this._hoverState.isOverIndicator || this._fisheyeState.isActive) return;
const activePulse = wrapper.querySelector('.lm-nav-pulse--viewing, .lm-nav-pulse--current');
if (activePulse) activePulse.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
_updateIndicators() {
document.querySelectorAll('.lm-nav-pulse').forEach(pulse => {
if (pulse.dataset.action) return;
const idx = parseInt(pulse.dataset.index);
pulse.classList.toggle('lm-nav-pulse--viewing', idx === this.viewingIndex);
pulse.classList.toggle('lm-nav-pulse--current', idx === this.currentIndex);
});
document.querySelectorAll('.lm-nav-item').forEach(item => {
const idx = parseInt(item.dataset.index);
if (isNaN(idx)) return;
const isViewing = idx === this.viewingIndex;
item.classList.toggle('lm-nav-item--viewing', isViewing);
item.setAttribute('aria-selected', isViewing ? 'true' : 'false');
});
this._syncIndicatorScroll();
}
// ========================================
// 列表渲染
// ========================================
_renderList() {
const list = document.querySelector('.lm-nav-list');
const emptyState = document.querySelector('.lm-nav-empty-state');
if (!list) return;
const settings = Settings.getAll();
if (settings.panelView !== 'messages') { list.style.display = 'none'; return; }
list.style.display = '';
let msgs = this._getFilteredMessages();
if (this.searchQuery) msgs = msgs.filter(m => (m.text || '').toLowerCase().includes(this.searchQuery));
if (settings.reversedOrder) msgs = [...msgs].reverse();
const paginate = !!settings.paginatePanel;
const totalPages = paginate ? Math.ceil(msgs.length / CONFIG.PAGE_SIZE) : 1;
this.currentPage = utils.clamp(this.currentPage, 1, Math.max(1, totalPages));
const startIndex = paginate ? (this.currentPage - 1) * CONFIG.PAGE_SIZE : 0;
const pageMessages = paginate ? msgs.slice(startIndex, startIndex + CONFIG.PAGE_SIZE) : msgs;
list.innerHTML = '';
if (pageMessages.length === 0) {
if (emptyState) {
emptyState.innerHTML = `
<div class="lm-nav-empty-icon">${Icons.messageSquare('xl').outerHTML}</div>
<p>${this.searchQuery ? '沒有找到匹配的消息' : '暫無消息'}</p>
`;
emptyState.style.display = 'flex';
}
return;
}
if (emptyState) emptyState.style.display = 'none';
pageMessages.forEach(msg => list.appendChild(this._createMessageItem(msg)));
}
// 列表項使用 Grid 佈局
_createMessageItem(msg) {
// ★ 防禦性檢查
if (!msg) {
log('Warning: _createMessageItem received null/undefined message');
const placeholder = document.createElement('li');
placeholder.className = 'lm-nav-item lm-nav-item--error';
placeholder.textContent = '無效消息';
return placeholder;
}
const isViewing = msg.index === this.viewingIndex;
const isFav = this._isMessageFavorited(msg);
const timestampMode = Settings.get('timestampMode');
const li = document.createElement('li');
li.className = 'lm-nav-item';
li.dataset.index = msg.index;
li.setAttribute('role', 'option');
li.setAttribute('aria-selected', isViewing ? 'true' : 'false');
if (msg.type === 'ai') li.classList.add('lm-nav-item--ai');
if (isViewing) li.classList.add('lm-nav-item--viewing');
// Badge: 序號 + 圖標
const badge = document.createElement('div');
badge.className = 'lm-nav-item-badge';
const num = document.createElement('span');
num.className = 'lm-nav-item-num';
num.textContent = `${msg.index + 1}`;
const icon = document.createElement('span');
icon.className = 'lm-nav-item-icon';
icon.appendChild(msg.type === 'user' ? Icons.user('xs') : Icons.bot('xs'));
badge.appendChild(num);
badge.appendChild(icon);
// 主文字區域
const textArea = document.createElement('div');
textArea.className = 'lm-nav-item-text-area';
const text = document.createElement('span');
text.className = 'lm-nav-item-text';
text.title = msg.text;
if (this.searchQuery) {
text.innerHTML = utils.highlightText(msg.getPreview(), this.searchQuery);
} else {
text.textContent = msg.getPreview();
}
textArea.appendChild(text);
// 次級資訊(懸停顯示)
const meta = document.createElement('div');
meta.className = 'lm-nav-item-meta';
if (timestampMode === 'show') {
const ts = MessageTimestamps.getTime(Favorites.getPageId(), msg.text);
if (ts) {
const timestamp = document.createElement('span');
timestamp.className = 'lm-nav-item-timestamp';
timestamp.textContent = MessageTimestamps.formatTimestamp(ts);
meta.appendChild(timestamp);
}
}
const actions = document.createElement('div');
actions.className = 'lm-nav-item-meta-actions';
const copyBtn = document.createElement('button');
copyBtn.className = 'lm-nav-icon-btn lm-nav-copy-btn';
copyBtn.dataset.text = msg.text;
copyBtn.title = '複製';
copyBtn.tabIndex = -1; // 預設不可聚焦
copyBtn.setAttribute('aria-hidden', 'true');
copyBtn.appendChild(Icons.copy('xs'));
actions.appendChild(copyBtn);
const insertBtn = document.createElement('button');
insertBtn.className = 'lm-nav-icon-btn lm-nav-insert-btn';
insertBtn.dataset.text = msg.text;
insertBtn.title = '插入到輸入框';
insertBtn.tabIndex = -1; // 預設不可聚焦
insertBtn.setAttribute('aria-hidden', 'true');
insertBtn.appendChild(Icons.cornerDownLeft('xs'));
actions.appendChild(insertBtn);
meta.appendChild(actions);
textArea.appendChild(meta);
// 收藏按鈕(固定右側)
const starBtn = document.createElement('button');
starBtn.className = `lm-nav-icon-btn lm-nav-star-btn ${isFav ? 'is-favorite' : ''}`;
starBtn.dataset.msgId = msg.id;
starBtn.dataset.msgIndex = msg.index;
starBtn.title = isFav ? '取消收藏' : '收藏';
starBtn.appendChild(isFav ? Icons.starFilled('sm') : Icons.starOutline('sm'));
li.appendChild(badge);
li.appendChild(textArea);
li.appendChild(starBtn);
// 處理 meta actions 的可達性
// ★ 使用通用方法替代重複的事件綁定
utils.setupAccessibleHover(li, '.lm-nav-item-meta-actions button');
return li;
}
// ========================================
// 收藏列表渲染
// ========================================
_renderFavorites() {
const list = document.querySelector('.lm-nav-favorites-list');
const emptyState = document.querySelector('.lm-nav-empty-state');
if (!list) return;
const settings = Settings.getAll();
if (settings.panelView !== 'favorites') { list.style.display = 'none'; return; }
list.style.display = '';
let favs = this._getFilteredFavoritesForView();
favs = [...favs].sort((a, b) => settings.reversedOrder ? (b.timestamp - a.timestamp) : (a.timestamp - b.timestamp));
const paginate = !!settings.paginatePanel;
const totalPages = paginate ? Math.ceil(favs.length / CONFIG.PAGE_SIZE) : 1;
this.currentPage = utils.clamp(this.currentPage, 1, Math.max(1, totalPages));
const startIndex = paginate ? (this.currentPage - 1) * CONFIG.PAGE_SIZE : 0;
const pageFavs = paginate ? favs.slice(startIndex, startIndex + CONFIG.PAGE_SIZE) : favs;
const favItems = [], idsToUpdate = [];
pageFavs.forEach(fav => {
const msg = Favorites.findMatchingMessage(this.messages, fav);
if (msg) {
favItems.push({ msg, fav });
if (fav.id !== msg.id) idsToUpdate.push({ oldId: fav.id, newId: msg.id });
} else {
favItems.push({ msg: null, fav });
}
});
if (idsToUpdate.length > 0) {
idsToUpdate.forEach(({ oldId, newId }) => Favorites.updateFavoriteId(Favorites.getPageId(), oldId, newId));
}
list.innerHTML = '';
if (favItems.length === 0) {
if (emptyState) {
const mode = settings.messageDisplayMode;
if (this.searchQuery) {
emptyState.innerHTML = `
<div class="lm-nav-empty-icon">${Icons.search('xl').outerHTML}</div>
<p>沒有找到匹配的收藏</p>
`;
} else if (mode === 'user' || mode === 'ai') {
emptyState.innerHTML = `
<div class="lm-nav-empty-icon">${Icons.starOutline('xl').outerHTML}</div>
<p>目前只顯示「${mode === 'user' ? '用戶' : 'AI'}收藏」</p>
<p class="lm-nav-empty-hint">切換顯示模式可查看其他收藏</p>
`;
} else {
emptyState.innerHTML = `
<div class="lm-nav-empty-icon">${Icons.starOutline('xl').outerHTML}</div>
<p>本頁暫無收藏</p>
<p class="lm-nav-empty-hint">點擊消息旁的星號添加收藏</p>
`;
}
emptyState.style.display = 'flex';
}
return;
}
if (emptyState) emptyState.style.display = 'none';
favItems.forEach(({ msg, fav }) => list.appendChild(this._createFavoriteItem(msg, fav)));
}
_createFavoriteItem(msg, fav) {
// ★ 防禦性檢查
if (!fav) {
log('Warning: _createFavoriteItem received null/undefined favorite');
const placeholder = document.createElement('li');
placeholder.className = 'lm-nav-item lm-nav-item--error';
placeholder.textContent = '無效收藏';
return placeholder;
}
const li = document.createElement('li');
li.className = 'lm-nav-item lm-nav-fav-item';
li.setAttribute('role', 'option');
const isNavigable = msg !== null;
const msgType = msg?.type || fav.type || 'user';
const timestampMode = Settings.get('timestampMode');
if (isNavigable) li.dataset.index = msg.index;
else li.classList.add('lm-nav-fav-item--orphan');
// Badge
const badge = document.createElement('div');
badge.className = 'lm-nav-item-badge';
const num = document.createElement('span');
num.className = 'lm-nav-item-num';
num.textContent = isNavigable ? `${msg.index + 1}` : '?';
const icon = document.createElement('span');
icon.className = 'lm-nav-item-icon';
icon.appendChild(msgType === 'user' ? Icons.user('xs') : Icons.bot('xs'));
badge.appendChild(num);
badge.appendChild(icon);
// 文字區域
const textArea = document.createElement('div');
textArea.className = 'lm-nav-item-text-area';
const text = document.createElement('span');
text.className = 'lm-nav-item-text';
const displayText = isNavigable ? msg.getPreview() : (fav.preview || utils.truncate(fav.text, 50));
if (this.searchQuery) {
text.innerHTML = utils.highlightText(displayText, this.searchQuery);
} else {
text.textContent = displayText;
}
text.title = isNavigable ? msg.text : fav.text;
textArea.appendChild(text);
// 次級資訊
const meta = document.createElement('div');
meta.className = 'lm-nav-item-meta';
if (timestampMode === 'show') {
const msgText = isNavigable ? msg.text : fav.text;
let ts = MessageTimestamps.getTime(Favorites.getPageId(), msgText);
if (!ts && fav.timestamp) ts = fav.timestamp;
if (ts) {
const timestamp = document.createElement('span');
timestamp.className = 'lm-nav-item-timestamp';
timestamp.textContent = MessageTimestamps.formatTimestamp(ts);
meta.appendChild(timestamp);
}
}
const actions = document.createElement('div');
actions.className = 'lm-nav-item-meta-actions';
const copyBtn = document.createElement('button');
copyBtn.className = 'lm-nav-icon-btn lm-nav-copy-btn';
copyBtn.dataset.text = isNavigable ? msg.text : fav.text;
copyBtn.title = '複製';
copyBtn.tabIndex = -1;
copyBtn.setAttribute('aria-hidden', 'true');
copyBtn.appendChild(Icons.copy('xs'));
actions.appendChild(copyBtn);
const insertBtn = document.createElement('button');
insertBtn.className = 'lm-nav-icon-btn lm-nav-insert-btn';
insertBtn.dataset.text = isNavigable ? msg.text : fav.text;
insertBtn.title = '插入到輸入框';
insertBtn.tabIndex = -1;
insertBtn.setAttribute('aria-hidden', 'true');
insertBtn.appendChild(Icons.cornerDownLeft('xs'));
actions.appendChild(insertBtn);
meta.appendChild(actions);
textArea.appendChild(meta);
// 收藏按鈕
const starBtn = document.createElement('button');
starBtn.className = 'lm-nav-icon-btn lm-nav-star-btn is-favorite';
starBtn.dataset.msgId = isNavigable ? msg.id : fav.id;
starBtn.dataset.msgIndex = isNavigable ? msg.index : -1;
starBtn.title = '取消收藏';
starBtn.appendChild(Icons.starFilled('sm'));
li.appendChild(badge);
li.appendChild(textArea);
li.appendChild(starBtn);
// 點擊事件
if (isNavigable) {
li.addEventListener('click', (e) => {
if (this._panelDrag.moved || this._panelDrag.justDragged || e.target.closest('button')) return;
this.navigateToIndex(msg.index);
});
} else {
li.addEventListener('click', (e) => {
if (this._panelDrag.moved || this._panelDrag.justDragged || e.target.closest('button')) return;
toast.info('此消息在當前頁面中找不到');
});
}
utils.setupAccessibleHover(li, '.lm-nav-item-meta-actions button');
return li;
}
// ========================================
// 分頁與統計渲染
// ========================================
_renderPagination() {
const pagination = document.querySelector('.lm-nav-pagination');
const stats = document.querySelector('.lm-nav-stats');
if (!pagination) return;
const prevBtn = pagination.querySelector('.lm-nav-page-prev');
const nextBtn = pagination.querySelector('.lm-nav-page-next');
const pageInfo = pagination.querySelector('.lm-nav-page-info');
const settings = Settings.getAll();
const view = settings.panelView;
// 計算總數
let totalItems, filteredItems;
if (view === 'messages') {
const msgs = this._getFilteredMessages();
totalItems = msgs.length;
filteredItems = this.searchQuery ? msgs.filter(m => (m.text || '').toLowerCase().includes(this.searchQuery)).length : totalItems;
} else {
const favs = this._getFilteredFavoritesForView();
totalItems = Favorites.getCurrentPageCount();
filteredItems = favs.length;
}
// 更新統計(messages 用「條」,favorites 用「項」)
if (stats) {
const unit = view === 'messages' ? '條' : '項';
if (this.searchQuery && filteredItems !== totalItems) {
stats.textContent = `找到 ${filteredItems}/${totalItems} ${unit}`;
} else {
stats.textContent = `共 ${totalItems} ${unit}`;
}
}
// 分頁控制
if (!settings.paginatePanel || filteredItems <= CONFIG.PAGE_SIZE) {
if (prevBtn) prevBtn.style.display = 'none';
if (nextBtn) nextBtn.style.display = 'none';
if (pageInfo) pageInfo.textContent = '';
return;
}
const totalPages = Math.ceil(filteredItems / CONFIG.PAGE_SIZE);
this.currentPage = utils.clamp(this.currentPage, 1, totalPages);
if (prevBtn) {
prevBtn.style.display = 'flex';
prevBtn.disabled = this.currentPage === 1;
}
if (nextBtn) {
nextBtn.style.display = 'flex';
nextBtn.disabled = this.currentPage === totalPages;
}
if (pageInfo) {
pageInfo.textContent = `${this.currentPage}/${totalPages}`;
}
}
_getTotalPagesForCurrentView() {
const settings = Settings.getAll();
if (!settings.paginatePanel) return 1;
if (settings.panelView === 'messages') {
let msgs = this._getFilteredMessages();
if (this.searchQuery) msgs = msgs.filter(m => (m.text || '').toLowerCase().includes(this.searchQuery));
return Math.max(1, Math.ceil(msgs.length / CONFIG.PAGE_SIZE));
}
return Math.max(1, Math.ceil(this._getFilteredFavoritesForView().length / CONFIG.PAGE_SIZE));
}
_goToPage(page) {
if (!Settings.get('paginatePanel')) return;
const totalPages = this._getTotalPagesForCurrentView();
const newPage = (page === 'last') ? totalPages : utils.clamp(page, 1, totalPages);
if (newPage === this.currentPage) return;
this.currentPage = newPage;
if (Settings.get('panelView') === 'messages') this._renderList();
else this._renderFavorites();
this._renderPagination();
}
// ========================================
// 輔助方法
// ========================================
async _copyText(text, btn) {
if (!text) { toast.error('沒有可複製的內容'); return; }
const success = await utils.copyToClipboard(text);
if (success) {
toast.success('已複製');
if (btn) {
btn.classList.add('copied');
setTimeout(() => btn.classList.remove('copied'), 1200);
}
} else {
toast.error('複製失敗');
}
}
_insertToInput(text) {
if (!text) { toast.error('沒有可插入的內容'); return false; }
const textarea = document.querySelector('textarea[placeholder*="Message"], textarea[placeholder*="message"], textarea');
if (!textarea) { toast.error('找不到輸入框'); return false; }
const start = textarea.selectionStart || 0, end = textarea.selectionEnd || 0, value = textarea.value || '';
textarea.value = value.substring(0, start) + text + value.substring(end);
textarea.focus();
textarea.selectionStart = textarea.selectionEnd = start + text.length;
textarea.dispatchEvent(new Event('input', { bubbles: true }));
toast.success('已插入');
return true;
}
closeAnyOpenPanel() {
let closed = false;
if (this.contextMenu?.isVisible) { this.contextMenu.hide(); closed = true; }
if (this.settingsDialog?.isOpen || this.isSettingsOpen) { this._closeSettingsDialog?.(); closed = true; }
if (this.favManagerDialog?.isOpen || this.isFavManagerOpen) { this._closeFavManagerDialog?.(); closed = true; }
if (this.isPanelOpen) { this._hidePanel(); closed = true; }
if (this._keyCapture.active) { this._endKeyCapture(true); closed = true; }
return closed;
}
resetAllPositions() {
FabPosition.reset();
PanelPosition.reset();
PanelSize.reset();
IndicatorPosition.reset();
const fab = document.getElementById(CONFIG.FAB_ID);
if (fab) {
const pos = FabPosition.load();
fab.style.bottom = `${pos.bottom}px`;
fab.style.right = `${pos.right}px`;
}
this._applyIndicatorPosition();
this._applyPanelSize();
if (this.isPanelOpen) this._positionPanel(null, false);
}
// ========================================
// 設定面板 (省略具體實現)
// ========================================
_buildSettingsContent() {
const settings = Settings.getAll();
const container = document.createElement('div');
container.className = 'lm-nav-settings';
// 顯示設定區塊
const visibility = this._createSettingSection('顯示', Icons.eye('sm'));
visibility.appendChild(this._createCheckboxPairRow(
{ label: '顯示浮動鈕', key: 'showFab', value: settings.showFab },
{ label: '自動隱藏浮動鈕', key: 'autoHideFab', value: settings.autoHideFab }
));
visibility.appendChild(this._createCheckboxPairRow(
{ label: '顯示指示條', key: 'showIndicator', value: settings.showIndicator },
{ label: '自動隱藏指示條', key: 'autoHideIndicator', value: settings.autoHideIndicator }
));
visibility.appendChild(this._createCheckboxPairRow(
{ label: '指示條顯示 AI 回覆', key: 'showAIInIndicator', value: settings.showAIInIndicator },
{ label: '指示條自動縮小', key: 'autoCollapseIndicator', value: settings.autoCollapseIndicator }
));
visibility.appendChild(this._createCheckboxPairRow(
{ label: '脈衝寬度反映訊息長度', key: 'indicatorShowLength', value: settings.indicatorShowLength },
{ label: '標記已收藏的訊息', key: 'indicatorShowFavorites', value: settings.indicatorShowFavorites }
));
container.appendChild(visibility);
// 外觀設定區塊
const appearance = this._createSettingSection('外觀', Icons.palette('sm'));
appearance.appendChild(this._createSelectSetting('字型大小', 'fontSize', FONT_SIZE_OPTIONS.map(o => ({ value: o.value, label: o.label })), settings.fontSize));
appearance.appendChild(this._createSelectSetting('字型樣式', 'fontFamily', Object.entries(FONT_OPTIONS).map(([key, opt]) => ({ value: key, label: opt.label })), settings.fontFamily));
appearance.appendChild(this._createSliderSetting('浮動鈕不透明度', 'fabOpacity', 30, 100, settings.fabOpacity, '%'));
appearance.appendChild(this._createSliderSetting('指示條不透明度', 'indicatorOpacity', 30, 100, settings.indicatorOpacity, '%'));
appearance.appendChild(this._createSliderSetting('浮動鈕大小', 'fabScale', 60, 140, settings.fabScale, '%'));
appearance.appendChild(this._createCheckboxSetting('列表項緊湊模式', 'listItemCompact', settings.listItemCompact));
container.appendChild(appearance);
// 行為設定區塊
const behavior = this._createSettingSection('行為', Icons.sliders('sm'));
behavior.appendChild(this._createCheckboxSetting('懸停指示條時顯示導航板', 'hoverShowPanel', settings.hoverShowPanel));
behavior.appendChild(this._createCheckboxSetting('啟用動畫效果', 'enableAnimation', settings.enableAnimation));
behavior.appendChild(this._createCheckboxSetting('在導覽板上啟用分頁(每頁20條消息)', 'paginatePanel', settings.paginatePanel));
behavior.appendChild(this._createSelectSetting('跳轉位置', 'scrollPosition', [
{ value: 'start', label: '訊息開頭' },
{ value: 'center', label: '訊息中央' }
], settings.scrollPosition));
// ★ 可選:預設顯示模式
behavior.appendChild(this._createSelectSetting('預設顯示模式', 'messageDisplayMode', [
{ value: 'user', label: '僅用戶消息' },
{ value: 'ai', label: '僅 AI 回覆' },
{ value: 'both', label: '全部消息' }
], settings.messageDisplayMode));
// ★ 可選:預設排序
behavior.appendChild(this._createCheckboxSetting('預設倒序排列(新消息在前)', 'reversedOrder', settings.reversedOrder));
container.appendChild(behavior);
// 時間戳記設定
const timestamp = this._createSettingSection('時間戳記', Icons.info('sm'));
timestamp.appendChild(this._createSelectSetting('時間戳記模式', 'timestampMode', [
{ value: 'none', label: '不記錄' },
{ value: 'record', label: '僅記錄(不顯示)' },
{ value: 'show', label: '記錄並顯示' }
], settings.timestampMode || 'record'));
timestamp.appendChild(this._createCheckboxSetting('顯示時刻(時:分)', 'timestampShowTime', settings.timestampShowTime !== false));
timestamp.appendChild(this._createSelectSetting('保留天數', 'messageTimestampRetention', [
{ value: 0, label: '不保留' },
{ value: 7, label: '7 天' },
{ value: 14, label: '14 天(預設)' },
{ value: 30, label: '30 天' },
{ value: 90, label: '90 天' },
{ value: -1, label: '永久保留' }
], settings.messageTimestampRetention));
container.appendChild(timestamp);
// 快捷鍵設定
const shortcuts = this._createSettingSection('快捷鍵', Icons.keyboard('sm'));
shortcuts.appendChild(this._buildKeybindingsUI());
container.appendChild(shortcuts);
// 操作按鈕
const actions = document.createElement('div');
actions.className = 'lm-nav-settings-actions';
const resetPosBtn = document.createElement('button');
resetPosBtn.className = 'lm-nav-btn';
resetPosBtn.textContent = '重置位置';
resetPosBtn.addEventListener('click', () => { this.resetAllPositions(); toast.success('位置已重置'); });
const resetAllBtn = document.createElement('button');
resetAllBtn.className = 'lm-nav-btn lm-nav-btn--danger';
resetAllBtn.textContent = '重置全部';
resetAllBtn.addEventListener('click', () => {
if (confirm('確定要重置所有設定嗎?')) {
Settings.reset();
Keybindings.reset();
FabPosition.reset();
PanelPosition.reset();
PanelSize.reset();
IndicatorPosition.reset();
this.settingsDialog?.close();
toast.success('設定已重置');
setTimeout(() => location.reload(), 500);
}
});
actions.appendChild(resetPosBtn);
actions.appendChild(resetAllBtn);
container.appendChild(actions);
return container;
}
// 設定面板輔助方法(初步呈現)
_createSettingSection(title, icon) {
const section = document.createElement('div');
section.className = 'lm-nav-setting-section';
const header = document.createElement('div');
header.className = 'lm-nav-setting-section-header';
const iconEl = document.createElement('span');
iconEl.className = 'lm-nav-setting-section-icon';
iconEl.appendChild(icon);
const titleEl = document.createElement('span');
titleEl.className = 'lm-nav-setting-section-title';
titleEl.textContent = title;
header.appendChild(iconEl);
header.appendChild(titleEl);
section.appendChild(header);
return section;
}
_createSelectSetting(label, key, options, currentValue) {
const row = document.createElement('div');
row.className = 'lm-nav-setting-row';
const labelEl = document.createElement('label');
labelEl.className = 'lm-nav-setting-label';
labelEl.textContent = label;
const select = document.createElement('select');
select.className = 'lm-nav-select';
options.forEach(opt => {
const option = document.createElement('option');
option.value = opt.value;
option.textContent = opt.label;
option.selected = opt.value == currentValue;
select.appendChild(option);
});
select.addEventListener('change', () => {
let value = select.value;
if (!isNaN(value) && value !== '') value = parseInt(value);
Settings.set(key, value);
});
row.appendChild(labelEl);
row.appendChild(select);
return row;
}
_createSliderSetting(label, key, min, max, currentValue, unit = '') {
const row = document.createElement('div');
row.className = 'lm-nav-setting-row lm-nav-setting-row--slider';
const labelEl = document.createElement('label');
labelEl.className = 'lm-nav-setting-label';
labelEl.textContent = label;
const sliderContainer = document.createElement('div');
sliderContainer.className = 'lm-nav-slider-container';
const slider = document.createElement('input');
slider.type = 'range';
slider.className = 'lm-nav-slider';
slider.min = min;
slider.max = max;
slider.value = currentValue;
const valueDisplay = document.createElement('span');
valueDisplay.className = 'lm-nav-slider-value';
valueDisplay.textContent = `${currentValue}${unit}`;
slider.addEventListener('input', () => {
const value = parseInt(slider.value);
valueDisplay.textContent = `${value}${unit}`;
Settings.set(key, value);
});
sliderContainer.appendChild(slider);
sliderContainer.appendChild(valueDisplay);
row.appendChild(labelEl);
row.appendChild(sliderContainer);
return row;
}
_createCheckboxSetting(label, key, currentValue) {
const row = document.createElement('div');
row.className = 'lm-nav-setting-row lm-nav-setting-row--checkbox';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'lm-nav-checkbox';
checkbox.id = `lm-nav-setting-${key}`;
checkbox.checked = !!currentValue;
checkbox.addEventListener('change', () => Settings.set(key, checkbox.checked));
const labelEl = document.createElement('label');
labelEl.className = 'lm-nav-setting-label';
labelEl.setAttribute('for', checkbox.id);
labelEl.textContent = label;
row.appendChild(checkbox);
row.appendChild(labelEl);
return row;
}
_createCheckboxPairRow(left, right) {
const row = document.createElement('div');
row.className = 'lm-nav-setting-row lm-nav-setting-row--pair';
const createCell = (cfg) => {
const wrap = document.createElement('div');
wrap.className = 'lm-nav-setting-cell';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'lm-nav-checkbox';
checkbox.id = `lm-nav-setting-${cfg.key}`;
checkbox.checked = !!cfg.value;
checkbox.addEventListener('change', () => Settings.set(cfg.key, checkbox.checked));
const labelEl = document.createElement('label');
labelEl.className = 'lm-nav-setting-label';
labelEl.setAttribute('for', checkbox.id);
labelEl.textContent = cfg.label;
wrap.appendChild(checkbox);
wrap.appendChild(labelEl);
return wrap;
};
row.appendChild(createCell(left));
row.appendChild(createCell(right));
return row;
}
_buildKeybindingsUI() {
const box = document.createElement('div');
box.className = 'lm-nav-keybind-box';
const hint = document.createElement('div');
hint.className = 'lm-nav-keybind-hint';
hint.textContent = '點擊「編輯」後按下新的組合鍵';
box.appendChild(hint);
const list = document.createElement('div');
list.className = 'lm-nav-keybind-list';
Object.keys(KEYBINDING_LABELS).forEach(action => {
const row = document.createElement('div');
row.className = 'lm-nav-keybind-row';
row.dataset.action = action;
const desc = document.createElement('span');
desc.className = 'lm-nav-keybind-desc';
desc.textContent = KEYBINDING_LABELS[action];
const key = document.createElement('kbd');
key.className = 'lm-nav-keybind-key';
key.textContent = Keybindings.formatBinding(Keybindings.get(action));
const editBtn = document.createElement('button');
editBtn.className = 'lm-nav-keybind-btn';
editBtn.title = '編輯';
editBtn.appendChild(Icons.edit('xs'));
editBtn.addEventListener('click', () => this._startKeyCapture(action));
const right = document.createElement('div');
right.className = 'lm-nav-keybind-right';
right.appendChild(key);
right.appendChild(editBtn);
row.appendChild(desc);
row.appendChild(right);
list.appendChild(row);
});
box.appendChild(list);
return box;
}
_startKeyCapture(action) {
if (!KEYBINDING_LABELS[action]) return;
if (this._keyCapture.active) this._endKeyCapture(true);
const label = KEYBINDING_LABELS[action] || action;
const current = Keybindings.get(action);
// 核心動作列表(不允許清除快捷鍵、必須包含修飾鍵)
const isCritical = (a) => ['togglePanel', 'toggleSettings', 'toggleFavManager'].includes(a);
// 建立 Dialog
const dlg = new Dialog({
title: `設定快捷鍵:${label}`,
width: 440,
className: 'lm-nav-keycapture-dialog'
});
const content = document.createElement('div');
content.className = 'lm-nav-keycapture';
content.innerHTML = `
<div class="lm-nav-keycapture-current">
<div class="lm-nav-keycapture-title">目前:</div>
<kbd class="lm-nav-keycapture-kbd">${Keybindings.formatBinding(current)}</kbd>
</div>
<div class="lm-nav-keycapture-tip">
<ul>
<li>請按下新的組合鍵</li>
<li><kbd>Esc</kbd> 取消</li>
<li><kbd>Backspace</kbd> / <kbd>Delete</kbd> 清除快捷鍵${isCritical(action) ? '(核心動作不可清除)' : ''}</li>
${isCritical(action) ? '<li>核心快捷鍵建議至少包含 <kbd>Alt</kbd> / <kbd>Ctrl</kbd> / <kbd>⌘</kbd></li>' : ''}
</ul>
</div>
`;
dlg.open().setContent(content);
// 標記設定頁的對應行
const row = document.querySelector(`.lm-nav-keybind-row[data-action="${action}"]`);
if (row) row.classList.add('is-capturing');
const handler = (e) => {
e.preventDefault();
e.stopPropagation();
if (typeof e.stopImmediatePropagation === 'function') {
e.stopImmediatePropagation();
}
// Esc 取消
if (e.key === 'Escape') {
this._endKeyCapture(true);
toast.info('已取消設定快捷鍵');
return;
}
// Backspace/Delete 清除
if (e.key === 'Backspace' || e.key === 'Delete') {
if (isCritical(action)) {
toast.error('核心動作不可清除快捷鍵');
return;
}
Keybindings.set(action, null);
this._endKeyCapture(false);
toast.success('快捷鍵已清除');
return;
}
// 忽略單獨的修飾鍵
if (['Shift', 'Control', 'Alt', 'Meta'].includes(e.key)) return;
const binding = Keybindings.fromEvent(e);
// 標準化 key(單字母轉小寫)
if (binding.key?.length === 1) {
binding.key = binding.key.toLowerCase();
}
// 核心快捷鍵必須包含修飾鍵
if (isCritical(action) && !binding.alt && !binding.ctrl && !binding.meta) {
toast.error('核心快捷鍵建議至少包含 Alt / Ctrl / ⌘');
return;
}
// 衝突檢測
const conflict = Keybindings.findConflict(action, binding);
if (conflict) {
if (isCritical(conflict)) {
toast.error(`與核心快捷鍵衝突:${KEYBINDING_LABELS[conflict]}`);
return;
}
const confirmMsg = `此快捷鍵已被「${KEYBINDING_LABELS[conflict]}」使用。\n是否將其從該功能移除並改給「${label}」?`;
if (!confirm(confirmMsg)) {
return;
}
Keybindings.set(conflict, null);
}
Keybindings.set(action, binding);
this._endKeyCapture(false);
toast.success('快捷鍵已更新');
};
document.addEventListener('keydown', handler, true);
this.keyboard.setEnabled(false);
this._keyCapture = { active: true, action, dialog: dlg, handler };
// 當 Dialog 被關閉時,自動結束捕獲
const origClose = dlg.close.bind(dlg);
dlg.close = () => {
origClose();
if (this._keyCapture.active) {
this._endKeyCapture(true, true);
}
};
}
_endKeyCapture(cancelled = false, skipDialogClose = false) {
if (!this._keyCapture.active) return;
// 移除事件監聽
try {
if (this._keyCapture.handler) {
document.removeEventListener('keydown', this._keyCapture.handler, true);
}
} catch (e) {}
// 恢復鍵盤導航
this.keyboard.setEnabled(true);
// 移除設定頁的捕獲標記
if (this._keyCapture.action) {
const row = document.querySelector(`.lm-nav-keybind-row[data-action="${this._keyCapture.action}"]`);
if (row) row.classList.remove('is-capturing');
}
// 關閉 Dialog(除非已經被關閉)
if (!skipDialogClose && this._keyCapture.dialog?.isOpen) {
try {
this._keyCapture.dialog.close();
} catch (e) {}
}
// 重置狀態
this._keyCapture = { active: false, action: null, dialog: null, handler: null };
// 刷新 UI
this._refreshKeybindingUI();
}
_refreshKeybindingUI() {
// 更新設定對話框中的快捷鍵顯示
const overlay = document.querySelector('.lm-nav-dialog-overlay.lm-nav-settings-dialog');
if (overlay) {
overlay.querySelectorAll('.lm-nav-keybind-row').forEach(row => {
const action = row.dataset.action;
const kbd = row.querySelector('.lm-nav-keybind-key');
if (kbd) kbd.textContent = Keybindings.formatBinding(Keybindings.get(action));
});
}
// 更新所有按鈕的 tooltip
const bindings = Keybindings.getAll();
// FAB
const fab = document.getElementById(CONFIG.FAB_ID);
if (fab) {
fab.title = `對話導航\n• 點擊:開啟導航板\n• 拖動:調整位置\n• 右鍵:更多選項\n快捷鍵: ${Keybindings.formatBinding(bindings.togglePanel || DEFAULT_KEYBINDINGS.togglePanel)}`;
}
// 面板內的按鈕
const displayModeBtn = document.querySelector('.lm-nav-display-mode-cycle');
if (displayModeBtn) {
const mode = Settings.get('messageDisplayMode') || 'user';
const modeLabels = { 'user': '僅用戶', 'ai': '僅 AI', 'both': '全部' };
displayModeBtn.title = `目前:${modeLabels[mode]}\n${this._getButtonTooltip('點擊切換', 'cycleDisplayMode')}`;
}
document.querySelector('.lm-nav-view-toggle')?.setAttribute('title', this._getButtonTooltip('切換視圖', 'toggleView'));
document.querySelector('.lm-nav-sort-btn')?.setAttribute('title', this._getButtonTooltip('切換排序', 'toggleOrder'));
document.querySelector('.lm-nav-load-btn')?.setAttribute('title', this._getButtonTooltip('載入更多歷史', 'loadHistory'));
document.querySelector('.lm-nav-manage-btn')?.setAttribute('title', this._getButtonTooltip('收藏夾管理', 'toggleFavManager'));
document.querySelector('.lm-nav-settings-btn')?.setAttribute('title', this._getButtonTooltip('設定', 'toggleSettings'));
this._updatePinButton();
this._updateDisplayModeCycleButton();
}
// ========================================
// 樣式注入
// ========================================
_injectStyles() {
const inject = () => {
const ok = addStyle(this._getStyles());
if (!ok) setTimeout(() => addStyle(this._getStyles()), 500);
};
if (document.head) inject();
else {
const observer = new MutationObserver(() => {
if (document.head) { observer.disconnect(); inject(); }
});
observer.observe(document.documentElement, { childList: true });
}
}
_getStyles() {
return `
/* ========================================
LMArena Chat Navigator v1.2.0 Styles
Design System: Unified spacing, radius, shadows
======================================== */
/* === Design Tokens === */
#${CONFIG.CONTAINER_ID} {
--lm-nav-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--lm-nav-font-size: 13px;
--lm-nav-font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
/* Spacing (4px base) */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
/* Radius */
--radius-xs: 4px;
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-full: 9999px;
/* Shadows */
--shadow-sm: 0 1px 3px rgba(0,0,0,0.08);
--shadow-md: 0 4px 12px rgba(0,0,0,0.1);
--shadow-lg: 0 8px 24px rgba(0,0,0,0.12);
/* Animation */
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--duration-fast: 100ms;
--duration-normal: 200ms;
--duration-slow: 300ms;
/* Runtime variables */
--lm-nav-fab-opacity: 0.9;
--lm-nav-fab-scale: 1;
--lm-nav-indicator-opacity: 0.85;
font-family: var(--lm-nav-font-family);
font-size: var(--lm-nav-font-size);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
/* === Light Theme === */
#${CONFIG.CONTAINER_ID}[data-theme="light"] {
--lm-nav-bg: #ffffff;
--lm-nav-bg-secondary: #f8fafc;
--lm-nav-bg-hover: rgba(0,0,0,0.04);
--lm-nav-bg-active: #dbeafe;
--lm-nav-border: #e2e8f0;
--lm-nav-border-light: #f1f5f9;
--lm-nav-text: #1e293b;
--lm-nav-text-secondary: #64748b;
--lm-nav-text-tertiary: #94a3b8;
--lm-nav-accent: #3b82f6;
--lm-nav-accent-hover: #2563eb;
--lm-nav-success: #22c55e;
--lm-nav-warning: #f59e0b;
--lm-nav-error: #ef4444;
--lm-nav-fab-bg: linear-gradient(135deg, #3b82f6, #2563eb);
--lm-nav-fab-shadow: 0 4px 14px rgba(59,130,246,0.35);
--lm-nav-pulse-user: #3b82f6;
--lm-nav-pulse-ai: #94a3b8;
--lm-nav-pulse-ai-b: #a78bfa;
}
/* === Dark Theme === */
#${CONFIG.CONTAINER_ID}[data-theme="dark"] {
--lm-nav-bg: #1e293b;
--lm-nav-bg-secondary: #334155;
--lm-nav-bg-hover: rgba(255,255,255,0.06);
--lm-nav-bg-active: rgba(59,130,246,0.25);
--lm-nav-border: #475569;
--lm-nav-border-light: #334155;
--lm-nav-text: #f1f5f9;
--lm-nav-text-secondary: #94a3b8;
--lm-nav-text-tertiary: #64748b;
--lm-nav-accent: #60a5fa;
--lm-nav-accent-hover: #3b82f6;
--lm-nav-success: #4ade80;
--lm-nav-warning: #fbbf24;
--lm-nav-error: #f87171;
--lm-nav-fab-bg: linear-gradient(135deg, #3b82f6, #1d4ed8);
--lm-nav-fab-shadow: 0 4px 14px rgba(59,130,246,0.25);
--lm-nav-pulse-user: #60a5fa;
--lm-nav-pulse-ai: #94a3b8;
--lm-nav-pulse-ai-b: #a5b4fc;
}
/* === Container === */
#${CONFIG.CONTAINER_ID} {
position: fixed;
inset: 0;
pointer-events: none;
z-index: ${CONFIG.Z_INDEX.INDICATOR};
}
#${CONFIG.CONTAINER_ID} * { box-sizing: border-box; }
#${CONFIG.CONTAINER_ID} .lm-nav-fab,
#${CONFIG.CONTAINER_ID} .lm-nav-indicator,
#${CONFIG.CONTAINER_ID} .lm-nav-panel,
#${CONFIG.CONTAINER_ID} .lm-nav-context-menu,
#${CONFIG.CONTAINER_ID} .lm-nav-dialog-overlay {
pointer-events: auto;
}
/* === Visibility Controls === */
#${CONFIG.CONTAINER_ID}[data-show-fab="false"] .lm-nav-fab { display: none !important; }
#${CONFIG.CONTAINER_ID}[data-show-indicator="false"] .lm-nav-indicator { display: none !important; }
#${CONFIG.CONTAINER_ID}[data-auto-hide-fab="true"] .lm-nav-fab {
opacity: 0;
pointer-events: none;
transition: opacity var(--duration-slow) var(--ease-out);
}
#${CONFIG.CONTAINER_ID}[data-auto-hide-fab="true"].lm-nav-fab--near .lm-nav-fab {
opacity: var(--lm-nav-fab-opacity);
pointer-events: auto;
}
#${CONFIG.CONTAINER_ID}[data-auto-hide-indicator="true"] .lm-nav-indicator {
opacity: 0;
pointer-events: none;
transition: opacity var(--duration-slow) var(--ease-out);
}
#${CONFIG.CONTAINER_ID}[data-auto-hide-indicator="true"].lm-nav-indicator--near .lm-nav-indicator {
opacity: var(--lm-nav-indicator-opacity);
pointer-events: auto;
}
/* === List Item Compact Mode === */
#lm-nav-container[data-list-compact="true"] .lm-nav-item {
padding: var(--space-1) var(--space-2);
}
#lm-nav-container[data-list-compact="true"] .lm-nav-item-text {
-webkit-line-clamp: 1;
font-size: calc(var(--lm-nav-font-size) - 2px);
}
#lm-nav-container[data-list-compact="true"] .lm-nav-item-badge {
flex-direction: row;
gap: var(--space-1);
}
#lm-nav-container[data-list-compact="true"] .lm-nav-item-meta {
height: 0 !important;
opacity: 0 !important;
}
/* === FAB === */
.lm-nav-fab {
position: fixed;
z-index: ${CONFIG.Z_INDEX.FAB};
width: 52px;
height: 52px;
border: none;
border-radius: var(--radius-full) !important;
background: var(--lm-nav-fab-bg) !important;
box-shadow: var(--lm-nav-fab-shadow);
color: #fff;
cursor: grab;
display: flex;
align-items: center;
justify-content: center;
opacity: var(--lm-nav-fab-opacity);
transform: scale(var(--lm-nav-fab-scale));
transition: box-shadow var(--duration-normal), transform var(--duration-normal);
}
.lm-nav-fab:hover {
box-shadow: var(--lm-nav-fab-shadow), 0 0 0 4px rgba(59,130,246,0.2);
}
.lm-nav-fab:focus-visible {
outline: 2px solid var(--lm-nav-accent);
outline-offset: 2px;
}
.lm-nav-fab--dragging {
cursor: grabbing;
transform: scale(calc(var(--lm-nav-fab-scale) * 1.05));
}
.lm-nav-fab svg { width: 24px; height: 24px; }
/* === Indicator (Pulse Design) === */
.lm-nav-indicator {
position: fixed;
z-index: ${CONFIG.Z_INDEX.INDICATOR};
padding: var(--space-5) var(--space-2);
max-height: 75vh;
overflow: visible;
opacity: var(--lm-nav-indicator-opacity);
cursor: grab;
transition: opacity var(--duration-normal), background var(--duration-normal), width var(--duration-normal);
border-radius: var(--radius-md);
background: transparent;
/* ★ 改為自適應寬度 */
width: auto;
min-width: 36px;
max-width: 56px;
}
/* ★ 外側 padding 永遠為 0:左側時 padding-left=0;右側時 padding-right=0 */
.lm-nav-indicator[data-edge="left"] {
padding-left: 0 !important;
}
.lm-nav-indicator[data-edge="right"] {
padding-right: 0 !important;
}
.lm-nav-indicator:hover {
opacity: 1;
background: var(--lm-nav-bg-hover);
}
.lm-nav-indicator--dragging {
cursor: grabbing;
opacity: 1;
}
.lm-nav-indicator-wrapper {
--lm-pulse-h: 4px;
--lm-pulse-gap: 6px;
display: flex;
flex-direction: column;
gap: var(--lm-pulse-gap);
padding: var(--space-1) 0;
max-height: calc(75vh - 40px);
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: none;
/* ★ 改為自適應寬度 */
width: max-content;
min-width: 24px;
max-width: 96px;
}
/* ★ 內側安全區:只在 hover(會 fisheye/展開)時加,外側仍然 0 */
.lm-nav-indicator:hover[data-edge="left"] .lm-nav-indicator-wrapper {
padding-right: 18px !important; /* 朝內容方向(右)留安全區 */
padding-left: 0 !important;
}
.lm-nav-indicator:hover[data-edge="right"] .lm-nav-indicator-wrapper {
padding-left: 18px !important; /* 朝內容方向(左)留安全區 */
padding-right: 0 !important;
}
.lm-nav-indicator-wrapper::-webkit-scrollbar { display: none; }
.lm-nav-indicator[data-edge="right"] .lm-nav-indicator-wrapper { align-items: flex-end; }
.lm-nav-indicator[data-edge="left"] .lm-nav-indicator-wrapper { align-items: flex-start; }
/* === Pulse (Individual Message Indicator) === */
.lm-nav-pulse {
position: relative;
height: var(--lm-pulse-h, 4px);
min-height: 2px;
width: var(--lm-pulse-w, 16px);
min-width: 8px;
max-width: 32px;
border-radius: var(--radius-full);
cursor: pointer;
opacity: 0.7;
flex-shrink: 0;
transform-origin: center center;
will-change: transform, filter, margin-bottom;
/* ★ 改進:更平滑的過渡,包含 margin-bottom */
transition:
opacity var(--duration-fast) ease-out,
transform 80ms ease-out,
filter 80ms ease-out,
margin-bottom 80ms ease-out;
}
.lm-nav-pulse--user { background: var(--lm-nav-pulse-user); }
.lm-nav-pulse--ai { background: var(--lm-nav-pulse-ai); }
.lm-nav-pulse--ai-a { background: var(--lm-nav-pulse-ai); }
.lm-nav-pulse--ai-b { background: var(--lm-nav-pulse-ai-b); }
.lm-nav-pulse--bottom {
width: 16px;
background: var(--lm-nav-success);
margin-top: var(--space-2);
}
.lm-nav-pulse:hover { opacity: 1; }
/* ★關鍵:依據指示條在左/右側,改變縮放基準點,避免放大後被裁切 */
.lm-nav-indicator[data-edge="right"] .lm-nav-pulse {
transform-origin: right center;
}
.lm-nav-indicator[data-edge="left"] .lm-nav-pulse {
transform-origin: left center;
}
/* === Favorite marker (refined, non-clipping) === */
/* 1) 輕微金色內發光:質感 + 不突兀 */
.lm-nav-pulse[data-favorite="true"] {
box-shadow:
inset 0 0 0 0.5px rgba(245, 158, 11, 0.55),
0 0 4px rgba(245, 158, 11, 0.18);
opacity: 0.9;
}
/* 2) autoCollapse 的「圓點狀態」:端帽拿掉,改成金色描邊圈(更精緻) */
#lm-nav-container[data-auto-collapse-indicator="true"] .lm-nav-indicator:not(:hover) .lm-nav-pulse[data-favorite="true"]::after {
display: none;
}
#lm-nav-container[data-auto-collapse-indicator="true"] .lm-nav-indicator:not(:hover) .lm-nav-pulse[data-favorite="true"] {
box-shadow:
inset 0 0 0 1px rgba(245, 158, 11, 0.85),
0 0 8px rgba(245, 158, 11, 0.22);
opacity: 0.95;
}
/* Active states */
.lm-nav-pulse--viewing {
opacity: 1;
box-shadow: 0 0 6px var(--lm-nav-accent);
}
.lm-nav-pulse--current {
opacity: 1;
background: var(--lm-nav-accent);
}
/* Fisheye active */
.lm-nav-indicator-wrapper.lm-nav-fisheye-active .lm-nav-pulse {
transition: transform 60ms ease-out, filter 60ms ease-out, opacity var(--duration-fast);
}
/* Hitbox expansion */
.lm-nav-pulse::before {
content: '';
position: absolute;
top: -6px;
bottom: -6px;
left: -4px;
right: -4px;
}
/* 自動縮小模式:非懸停時收窄容器 */
#${CONFIG.CONTAINER_ID}[data-auto-collapse-indicator="true"] .lm-nav-indicator:not(:hover) {
min-width: 22px;
max-width: 26px;
padding: var(--space-4) var(--space-1);
}
#${CONFIG.CONTAINER_ID}[data-auto-collapse-indicator="true"] .lm-nav-indicator:not(:hover) .lm-nav-indicator-wrapper {
min-width: 12px;
max-width: 16px;
padding: var(--space-1) 0;
}
/* 自動縮小模式:非懸停時為圓點 */
#${CONFIG.CONTAINER_ID}[data-auto-collapse-indicator="true"] .lm-nav-indicator:not(:hover) .lm-nav-pulse {
width: 6px;
height: 6px;
min-width: 6px;
max-width: 6px;
border-radius: 50%;
}
/* 自動縮小模式:懸停時恢復正常寬度 */
#${CONFIG.CONTAINER_ID}[data-auto-collapse-indicator="true"] .lm-nav-indicator:hover {
min-width: 40px;
max-width: 96px;
padding: var(--space-3) var(--space-2);
}
#${CONFIG.CONTAINER_ID}[data-auto-collapse-indicator="true"] .lm-nav-indicator:hover .lm-nav-indicator-wrapper {
min-width: 24px;
max-width: 96px;
padding: var(--space-1) 18px;
}
/* 自動縮小模式:懸停時恢復為線條(寬度由 JS 控制)*/
#${CONFIG.CONTAINER_ID}[data-auto-collapse-indicator="true"] .lm-nav-indicator:hover .lm-nav-pulse {
height: var(--lm-pulse-h, 4px);
min-width: 10px;
max-width: 32px;
border-radius: var(--radius-full);
}
/* === Panel === */
.lm-nav-panel {
position: fixed;
z-index: ${CONFIG.Z_INDEX.PANEL};
width: 380px;
max-height: 450px;
min-width: 300px;
min-height: 280px;
background: var(--lm-nav-bg);
border: 1px solid var(--lm-nav-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
display: flex;
flex-direction: column;
overflow: hidden;
opacity: 0;
visibility: hidden;
transform: scale(0.95) translateY(8px);
transition: opacity var(--duration-normal), visibility var(--duration-normal), transform var(--duration-slow) var(--ease-out);
}
.lm-nav-panel--open {
opacity: 1;
visibility: visible;
transform: scale(1) translateY(0);
}
.lm-nav-panel--dragging {
transition: none;
cursor: grabbing;
box-shadow: var(--shadow-lg), 0 0 0 2px var(--lm-nav-accent);
}
.lm-nav-panel--resizing { transition: none; }
.lm-nav-panel--pinned .lm-nav-pin-btn {
color: var(--lm-nav-accent);
background: rgba(59,130,246,0.1);
}
/* === Panel Header === */
.lm-nav-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--lm-nav-border-light);
cursor: grab;
flex-shrink: 0;
}
.lm-nav-panel--dragging .lm-nav-header { cursor: grabbing; }
.lm-nav-header-left {
display: flex;
align-items: center;
gap: var(--space-2);
}
.lm-nav-header-right { display: flex; align-items: center; gap: 2px; }
/* View Toggle */
.lm-nav-view-toggle {
display: flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-2);
border: 1px solid var(--lm-nav-border);
border-radius: var(--radius-sm);
background: var(--lm-nav-bg);
color: var(--lm-nav-text);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all var(--duration-normal);
}
.lm-nav-view-toggle:hover {
background: var(--lm-nav-bg-hover);
border-color: var(--lm-nav-accent);
}
.lm-nav-view-icon { display: flex; }
.lm-nav-view-icon svg { width: 14px; height: 14px; }
.lm-nav-view-arrow { display: flex; opacity: 0.6; }
.lm-nav-view-arrow svg { width: 10px; height: 10px; }
.lm-nav-fav-badge {
min-width: 12px;
height: 12px;
padding: 0 3px;
background: var(--lm-nav-accent);
color: #fff;
font-size: 10px;
font-weight: 700;
border-radius: var(--radius-full);
display: none;
align-items: center;
justify-content: center;
margin-left: 2px;
box-shadow: 0 0.5px 1px rgba(0, 0, 0, 0.1);
}
/* Action Buttons */
.lm-nav-action-btn {
width: 28px;
height: 28px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--lm-nav-text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--duration-normal);
}
.lm-nav-action-btn:hover {
background: var(--lm-nav-bg-hover);
color: var(--lm-nav-text);
}
.lm-nav-action-btn:focus-visible {
outline: 2px solid var(--lm-nav-accent);
outline-offset: 1px;
}
.lm-nav-action-btn svg { width: 14px; height: 14px; }
/* 顯示模式三態循環按鈕 */
.lm-nav-display-mode-cycle {
position: relative;
}
.lm-nav-display-mode-cycle::after {
content: '';
position: absolute;
bottom: 2px;
left: 50%;
transform: translateX(-50%);
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--lm-nav-accent);
opacity: 0.6;
}
.lm-nav-display-mode-cycle[data-mode="ai"]::after {
background: var(--lm-nav-text-tertiary);
}
.lm-nav-display-mode-cycle[data-mode="both"]::after {
width: 12px;
height: 3px;
border-radius: 2px;
}
/* === Search Row (含分頁和統計) === */
.lm-nav-search-row {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--lm-nav-border-light);
flex-shrink: 0;
}
.lm-nav-search-icon {
color: var(--lm-nav-text-tertiary);
display: flex;
flex-shrink: 0;
}
.lm-nav-search-input {
flex: 1;
min-width: 80px;
border: none;
background: transparent;
color: var(--lm-nav-text);
font-size: var(--lm-nav-font-size);
font-family: var(--lm-nav-font-family);
outline: none;
}
.lm-nav-search-input::placeholder { color: var(--lm-nav-text-tertiary); }
.lm-nav-search-clear {
width: 18px;
height: 18px;
border: none;
border-radius: var(--radius-full);
background: var(--lm-nav-bg-secondary);
color: var(--lm-nav-text-secondary);
cursor: pointer;
display: none;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.lm-nav-search-clear:hover { background: var(--lm-nav-border); }
/* 分頁控制 */
.lm-nav-pagination {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
.lm-nav-page-btn {
width: 20px;
height: 20px;
border: none;
border-radius: var(--radius-xs);
background: transparent;
color: var(--lm-nav-text-tertiary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--duration-fast);
}
.lm-nav-page-btn:hover:not(:disabled) {
background: var(--lm-nav-bg-hover);
color: var(--lm-nav-text);
}
.lm-nav-page-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.lm-nav-page-btn svg { width: 12px; height: 12px; }
.lm-nav-page-info {
font-size: 10px;
color: var(--lm-nav-text-tertiary);
font-variant-numeric: tabular-nums;
min-width: 24px;
text-align: center;
}
/* 統計 */
.lm-nav-stats {
font-size: 11px;
color: var(--lm-nav-text-tertiary);
white-space: nowrap;
flex-shrink: 0;
padding-left: var(--space-1);
border-left: 1px solid var(--lm-nav-border-light);
}
/* === Content Area === */
.lm-nav-content {
flex: 1;
overflow: hidden;
position: relative;
min-height: 0;
display: flex;
flex-direction: column;
}
.lm-nav-list,
.lm-nav-favorites-list {
list-style: none;
margin: 0;
padding: var(--space-1);
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
}
.lm-nav-panel[data-view="messages"] .lm-nav-favorites-list,
.lm-nav-panel[data-view="favorites"] .lm-nav-list { display: none; }
.lm-nav-panel[data-view="messages"] .lm-nav-list,
.lm-nav-panel[data-view="favorites"] .lm-nav-favorites-list { display: block; }
/* === List Item (Grid Layout) === */
.lm-nav-item {
display: grid;
grid-template-columns: 32px 1fr auto;
gap: var(--space-1);
padding: var(--space-2);
border-radius: var(--radius-sm);
cursor: pointer;
transition: background var(--duration-fast);
align-items: start;
}
.lm-nav-item:hover { background: var(--lm-nav-bg-hover); }
.lm-nav-item--viewing { background: var(--lm-nav-bg-active); }
.lm-nav-item--ai { opacity: 0.85; }
/* Badge (Number + Icon) */
.lm-nav-item-badge {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding-top: 2px;
}
.lm-nav-item-num {
font-size: 10px;
font-weight: 700;
color: var(--lm-nav-text-tertiary);
line-height: 1;
}
.lm-nav-item-icon {
color: var(--lm-nav-text-tertiary);
display: flex;
}
.lm-nav-item-icon svg { width: 12px; height: 12px; }
/* Text Area */
.lm-nav-item-text-area {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.lm-nav-item-text {
color: var(--lm-nav-text-secondary);
font-size: calc(var(--lm-nav-font-size) - 1px);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.lm-nav-item:hover .lm-nav-item-text,
.lm-nav-item--viewing .lm-nav-item-text {
color: var(--lm-nav-text);
}
/* Meta (Hidden by default, shown on hover) */
.lm-nav-item-meta {
display: flex;
align-items: center;
gap: var(--space-2);
height: 0;
overflow: hidden;
opacity: 0;
transition: height var(--duration-fast), opacity var(--duration-fast);
}
.lm-nav-item:hover .lm-nav-item-meta,
.lm-nav-item:focus-within .lm-nav-item-meta {
height: 22px;
opacity: 1;
}
/* 焦點在列表項內時的視覺指示 */
.lm-nav-item:focus-within {
background: var(--lm-nav-bg-hover);
outline: 2px solid var(--lm-nav-accent);
outline-offset: -2px;
border-radius: var(--radius-sm);
}
.lm-nav-item-timestamp {
font-size: 10px;
color: var(--lm-nav-text-tertiary);
}
.lm-nav-item-meta-actions {
display: flex;
gap: 2px;
margin-left: auto;
}
/* Star Button */
.lm-nav-star-btn {
opacity: 0.3;
transition: opacity var(--duration-fast);
align-self: center;
}
.lm-nav-item:hover .lm-nav-star-btn,
.lm-nav-star-btn.is-favorite {
opacity: 1;
}
.lm-nav-star-btn.is-favorite { color: var(--lm-nav-warning); }
/* Orphan Favorites */
.lm-nav-fav-item--orphan { opacity: 0.6; }
.lm-nav-fav-item--orphan .lm-nav-item-num { color: var(--lm-nav-warning); }
/* Icon Buttons */
.lm-nav-icon-btn {
width: 22px;
height: 22px;
border: none;
border-radius: var(--radius-xs);
background: transparent;
color: var(--lm-nav-text-tertiary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--duration-fast);
}
.lm-nav-icon-btn:hover {
background: var(--lm-nav-bg-secondary);
color: var(--lm-nav-text);
}
.lm-nav-icon-btn svg { width: 12px; height: 12px; }
.lm-nav-icon-btn.copied { color: var(--lm-nav-success); }
.lm-nav-icon-btn--danger:hover { color: var(--lm-nav-error); background: rgba(239,68,68,0.1); }
/* === Empty State === */
.lm-nav-empty-state {
display: none;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-6);
text-align: center;
color: var(--lm-nav-text-secondary);
position: absolute;
inset: 0;
background: var(--lm-nav-bg);
}
.lm-nav-empty-icon {
color: var(--lm-nav-text-tertiary);
margin-bottom: var(--space-2);
}
.lm-nav-empty-state p { margin: 0 0 2px; font-size: var(--lm-nav-font-size); }
.lm-nav-empty-hint { font-size: 12px; color: var(--lm-nav-text-tertiary); }
/* === Resize Handle === */
.lm-nav-resize-handle {
position: absolute;
right: 0;
bottom: 0;
width: 16px;
height: 16px;
cursor: nwse-resize;
opacity: 0;
transition: opacity var(--duration-normal);
}
.lm-nav-resize-handle::after {
content: '';
position: absolute;
right: 4px;
bottom: 4px;
width: 8px;
height: 8px;
border-right: 2px solid var(--lm-nav-border);
border-bottom: 2px solid var(--lm-nav-border);
}
.lm-nav-panel:hover .lm-nav-resize-handle { opacity: 0.5; }
.lm-nav-resize-handle:hover { opacity: 1 !important; }
/* === Highlight === */
.lm-nav-highlight {
background: var(--lm-nav-warning);
color: #000;
padding: 0 2px;
border-radius: 2px;
}
/* === Context Menu === */
.lm-nav-context-menu {
position: fixed;
z-index: ${CONFIG.Z_INDEX.MENU};
min-width: 180px;
padding: var(--space-1);
background: var(--lm-nav-bg);
border: 1px solid var(--lm-nav-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
display: none;
}
.lm-nav-context-item {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2);
border-radius: var(--radius-sm);
color: var(--lm-nav-text);
cursor: pointer;
outline: none;
transition: background var(--duration-fast);
}
.lm-nav-context-item:hover,
.lm-nav-context-item:focus { background: var(--lm-nav-bg-hover); }
.lm-nav-context-item--disabled { opacity: 0.5; cursor: not-allowed; }
.lm-nav-context-icon { width: 16px; display: flex; justify-content: center; color: var(--lm-nav-text-tertiary); }
.lm-nav-context-label { flex: 1; font-size: var(--lm-nav-font-size); }
.lm-nav-context-shortcut { font-size: 11px; font-family: var(--lm-nav-font-mono); color: var(--lm-nav-text-tertiary); }
.lm-nav-context-separator { height: 1px; margin: var(--space-1) var(--space-2); background: var(--lm-nav-border-light); }
/* === Toast === */
.lm-nav-toast-container {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
z-index: ${CONFIG.Z_INDEX.TOAST} !important;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
pointer-events: none;
}
.lm-nav-toast {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
background: var(--lm-nav-bg);
border: 1px solid var(--lm-nav-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
color: var(--lm-nav-text);
font-size: var(--lm-nav-font-size);
font-family: var(--lm-nav-font-family);
pointer-events: auto;
opacity: 0;
transform: translateY(10px) scale(0.95);
transition: opacity var(--duration-normal), transform var(--duration-slow) var(--ease-out);
}
.lm-nav-toast--visible {
opacity: 1;
transform: translateY(0) scale(1);
}
.lm-nav-toast--hiding {
opacity: 0;
transform: translateY(-10px) scale(0.95);
}
.lm-nav-toast-icon { display: flex; flex-shrink: 0; }
.lm-nav-toast--success .lm-nav-toast-icon { color: var(--lm-nav-success); }
.lm-nav-toast--error .lm-nav-toast-icon { color: var(--lm-nav-error); }
.lm-nav-toast--info .lm-nav-toast-icon { color: var(--lm-nav-accent); }
.lm-nav-toast--warning .lm-nav-toast-icon { color: var(--lm-nav-warning); }
.lm-nav-toast-text { flex: 1; }
.lm-nav-toast-action {
margin-left: var(--space-2);
padding: var(--space-1) var(--space-2);
border: none;
border-radius: var(--radius-sm);
background: var(--lm-nav-accent);
color: #fff;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: background var(--duration-fast);
}
.lm-nav-toast-action:hover { background: var(--lm-nav-accent-hover); }
/* === Dialog === */
.lm-nav-dialog-overlay {
position: fixed;
inset: 0;
z-index: ${CONFIG.Z_INDEX.DIALOG};
background: rgba(0,0,0,0.5);
-webkit-backdrop-filter: blur(4px);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
opacity: 0;
transition: opacity var(--duration-normal);
}
.lm-nav-dialog--visible { opacity: 1; }
.lm-nav-dialog {
width: 100%;
max-height: calc(100vh - 40px);
background: var(--lm-nav-bg);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
display: flex;
flex-direction: column;
overflow: hidden;
transform: scale(0.95);
transition: transform var(--duration-slow) var(--ease-out);
font-family: var(--lm-nav-font-family);
font-size: var(--lm-nav-font-size);
}
.lm-nav-dialog--visible .lm-nav-dialog { transform: scale(1); }
.lm-nav-dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--lm-nav-border-light);
background: var(--lm-nav-bg-secondary);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
}
.lm-nav-dialog-title {
font-size: 14px;
font-weight: 600;
color: var(--lm-nav-text);
margin: 0;
display: flex;
align-items: center;
gap: var(--space-2);
letter-spacing: -0.01em;
font-family: var(--lm-nav-font-family);
}
/* 標題前的裝飾標記 */
.lm-nav-dialog-title::before {
content: '';
display: inline-block;
width: 3px;
height: 14px;
background: var(--lm-nav-accent);
border-radius: 2px;
flex-shrink: 0;
}
/* 收藏夾 Dialog 的金色標記 */
.lm-nav-fav-manager-dialog .lm-nav-dialog-title::before {
background: var(--lm-nav-warning);
}
/* 設定 Dialog 的藍色標記(預設) */
.lm-nav-settings-dialog .lm-nav-dialog-title::before {
background: var(--lm-nav-accent);
}
.lm-nav-dialog-close {
width: 32px;
height: 32px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--lm-nav-text-tertiary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.lm-nav-dialog-close:hover { background: var(--lm-nav-bg-hover); color: var(--lm-nav-text); }
.lm-nav-dialog-content {
flex: 1;
overflow-y: auto;
padding: var(--space-4) var(--space-5);
}
/* === Settings === */
.lm-nav-settings { display: flex; flex-direction: column; gap: var(--space-4); }
.lm-nav-setting-section { display: flex; flex-direction: column; gap: var(--space-3); }
.lm-nav-setting-section-header {
display: flex;
align-items: center;
gap: var(--space-2);
padding-bottom: var(--space-2);
border-bottom: 1px solid var(--lm-nav-border-light);
}
.lm-nav-setting-section-icon { color: var(--lm-nav-text-tertiary); display: flex; }
.lm-nav-setting-section-title {
font-size: 12px;
font-weight: 700;
color: var(--lm-nav-text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.lm-nav-setting-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.lm-nav-setting-row--slider { flex-direction: column; align-items: stretch; }
.lm-nav-setting-row--checkbox { justify-content: flex-start; }
.lm-nav-setting-row--pair { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-3); }
.lm-nav-setting-cell { display: flex; align-items: center; gap: var(--space-2); }
.lm-nav-setting-label {
font-size: var(--lm-nav-font-size);
color: var(--lm-nav-text);
cursor: pointer;
}
.lm-nav-select {
padding: var(--space-1) var(--space-2);
border: 1px solid var(--lm-nav-border);
border-radius: var(--radius-sm);
background: var(--lm-nav-bg);
color: var(--lm-nav-text);
font-size: var(--lm-nav-font-size);
cursor: pointer;
}
.lm-nav-select:focus { outline: none; border-color: var(--lm-nav-accent); }
.lm-nav-slider-container { display: flex; align-items: center; gap: var(--space-3); }
.lm-nav-slider {
flex: 1;
height: 4px;
-webkit-appearance: none;
background: var(--lm-nav-border);
border-radius: 2px;
cursor: pointer;
}
.lm-nav-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px;
height: 16px;
background: var(--lm-nav-accent);
border-radius: 50%;
cursor: pointer;
}
.lm-nav-slider-value {
min-width: 40px;
text-align: right;
font-size: 12px;
color: var(--lm-nav-text-secondary);
}
.lm-nav-checkbox {
width: 16px;
height: 16px;
accent-color: var(--lm-nav-accent);
cursor: pointer;
}
.lm-nav-settings-actions {
display: flex;
gap: var(--space-2);
padding-top: var(--space-4);
border-top: 1px solid var(--lm-nav-border-light);
margin-top: var(--space-2);
}
.lm-nav-btn {
flex: 1;
padding: var(--space-2) var(--space-4);
border: 1px solid var(--lm-nav-border);
border-radius: var(--radius-sm);
background: var(--lm-nav-bg);
color: var(--lm-nav-text-secondary);
font-size: var(--lm-nav-font-size);
font-weight: 600;
cursor: pointer;
transition: all var(--duration-normal);
}
.lm-nav-btn:hover { background: var(--lm-nav-bg-hover); color: var(--lm-nav-text); }
.lm-nav-btn--danger:hover { background: var(--lm-nav-error); border-color: var(--lm-nav-error); color: #fff; }
/* === Keybindings === */
.lm-nav-keybind-box { display: flex; flex-direction: column; gap: var(--space-2); }
.lm-nav-keybind-hint { font-size: 12px; color: var(--lm-nav-text-tertiary); }
.lm-nav-keybind-list { display: flex; flex-direction: column; gap: var(--space-1); }
.lm-nav-keybind-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-2);
border: 1px solid var(--lm-nav-border-light);
border-radius: var(--radius-sm);
}
.lm-nav-keybind-row.is-capturing {
border-color: var(--lm-nav-accent);
box-shadow: 0 0 0 2px rgba(59,130,246,0.15);
}
.lm-nav-keybind-desc { font-size: var(--lm-nav-font-size); color: var(--lm-nav-text); }
.lm-nav-keybind-right { display: flex; align-items: center; gap: var(--space-2); }
.lm-nav-keybind-key {
font-family: var(--lm-nav-font-mono);
font-size: 11px;
padding: 2px var(--space-2);
background: var(--lm-nav-bg-secondary);
border: 1px solid var(--lm-nav-border);
border-radius: var(--radius-xs);
color: var(--lm-nav-text-secondary);
min-width: 60px;
text-align: center;
}
.lm-nav-keybind-btn {
width: 24px;
height: 24px;
border: none;
border-radius: var(--radius-xs);
background: transparent;
color: var(--lm-nav-text-tertiary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.lm-nav-keybind-btn:hover { background: var(--lm-nav-bg-hover); color: var(--lm-nav-text); }
/* Key Capture 捕獲狀態 */
.lm-nav-keybind-key.is-capturing {
background: var(--lm-nav-accent);
color: #fff;
border-color: var(--lm-nav-accent);
animation: lm-nav-key-capture-pulse 1.2s ease-in-out infinite;
}
@keyframes lm-nav-key-capture-pulse {
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.4); }
50% { opacity: 0.85; box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.2); }
}
/* === 快捷鍵捕獲 Dialog === */
.lm-nav-keycapture {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-4);
padding: var(--space-4);
}
.lm-nav-keycapture-current {
display: flex;
align-items: center;
gap: var(--space-3);
}
.lm-nav-keycapture-title {
font-size: var(--lm-nav-font-size);
color: var(--lm-nav-text-secondary);
}
.lm-nav-keycapture-kbd {
font-family: var(--lm-nav-font-mono);
font-size: 14px;
padding: var(--space-2) var(--space-4);
background: var(--lm-nav-bg-secondary);
border: 1px solid var(--lm-nav-border);
border-radius: var(--radius-sm);
color: var(--lm-nav-text);
min-width: 80px;
text-align: center;
}
.lm-nav-keycapture-tip {
font-size: 12px;
color: var(--lm-nav-text-secondary);
text-align: left;
width: 100%;
}
.lm-nav-keycapture-tip ul {
margin: 0;
padding-left: 20px;
line-height: 1.8;
}
.lm-nav-keycapture-tip kbd {
font-family: var(--lm-nav-font-mono);
font-size: 11px;
padding: 2px 6px;
background: var(--lm-nav-bg-secondary);
border: 1px solid var(--lm-nav-border);
border-radius: 3px;
}
/* === Favorites Manager === */
.lm-nav-fav-manager {
min-height: 220px;
font-family: var(--lm-nav-font-family);
font-size: var(--lm-nav-font-size);
}
.lm-nav-fav-manager-search { display: flex; align-items: center; gap: var(--space-2); padding: var(--space-2) var(--space-3); border: 1px solid var(--lm-nav-border); border-radius: var(--radius-md); background: var(--lm-nav-bg-secondary); margin-bottom: var(--space-2); }
.lm-nav-fav-manager-search-input { flex: 1; border: none; background: transparent; color: var(--lm-nav-text); font-size: var(--lm-nav-font-size); outline: none; }
.lm-nav-fav-manager-toolbar { display: flex; align-items: center; justify-content: space-between; padding: var(--space-2) 0; border-bottom: 1px solid var(--lm-nav-border-light); margin-bottom: var(--space-2); }
.lm-nav-fav-manager-toolbar-left, .lm-nav-fav-manager-toolbar-right { display: flex; align-items: center; gap: var(--space-2); }
.lm-nav-fav-manager-selection-info { color: var(--lm-nav-text-secondary); font-size: 12px; }
.lm-nav-btn-sm { padding: var(--space-1) var(--space-2); border: 1px solid var(--lm-nav-border); border-radius: var(--radius-sm); background: var(--lm-nav-bg); color: var(--lm-nav-text-secondary); font-size: 11px; font-weight: 600; cursor: pointer; display: inline-flex; align-items: center; gap: var(--space-1); }
.lm-nav-btn-sm:hover { background: var(--lm-nav-bg-hover); color: var(--lm-nav-text); }
.lm-nav-btn-sm--danger:hover { background: rgba(239,68,68,0.1); color: var(--lm-nav-error); }
.lm-nav-btn-xs { padding: 2px var(--space-2); border: 1px solid var(--lm-nav-border); border-radius: var(--radius-sm); background: var(--lm-nav-bg); color: var(--lm-nav-text-tertiary); font-size: 10px; cursor: pointer; }
.lm-nav-btn-xs:hover { background: var(--lm-nav-bg-hover); color: var(--lm-nav-text); }
.lm-nav-btn-xs--danger:hover { color: var(--lm-nav-error); }
.lm-nav-fav-manager-list { padding-top: var(--space-2); }
.lm-nav-fav-section { margin-bottom: var(--space-4); }
.lm-nav-fav-section-header { display: flex; align-items: center; justify-content: space-between; font-size: 12px; font-weight: 700; color: var(--lm-nav-text-secondary); padding-bottom: var(--space-2); border-bottom: 1px solid var(--lm-nav-border-light); margin-bottom: var(--space-2); }
.lm-nav-fav-section-list { list-style: none; margin: 0; padding: 0; }
.lm-nav-fav-manager-item { display: flex; align-items: center; gap: var(--space-2); padding: var(--space-2); border-radius: var(--radius-sm); transition: background var(--duration-fast); }
.lm-nav-fav-manager-item:hover { background: var(--lm-nav-bg-hover); }
.lm-nav-fav-manager-item.is-selected { background: var(--lm-nav-bg-active); }
.lm-nav-fav-manager-checkbox { width: 24px; height: 24px; border: none; background: transparent; color: var(--lm-nav-text-tertiary); cursor: pointer; display: flex; align-items: center; justify-content: center; }
.lm-nav-fav-manager-icon { color: var(--lm-nav-text-tertiary); display: flex; }
.lm-nav-fav-manager-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--lm-nav-text); font-size: var(--lm-nav-font-size); }
.lm-nav-fav-manager-time { font-size: 11px; color: var(--lm-nav-text-tertiary); }
.lm-nav-fav-manager-actions { display: flex; gap: 2px; opacity: 0; transition: opacity var(--duration-fast); }
.lm-nav-fav-manager-item:hover .lm-nav-fav-manager-actions { opacity: 1; }
.lm-nav-fav-manager-item--pending { opacity: 0.7; }
.lm-nav-fav-manager-item--pending .lm-nav-fav-manager-actions { opacity: 1; }
.lm-nav-fav-undo-btn { color: var(--lm-nav-warning); }
/* 消息時間戳記:非懸停時顯示 */
.lm-nav-fav-manager-msg-time {
font-size: 11px;
color: var(--lm-nav-text-tertiary);
white-space: nowrap;
margin-left: auto;
padding-right: var(--space-1);
transition: opacity var(--duration-fast);
}
/* 懸停時隱藏時間戳記 */
.lm-nav-fav-manager-item:hover .lm-nav-fav-manager-time {
opacity: 0;
pointer-events: none;
}
.lm-nav-fav-manager-item:hover .lm-nav-fav-manager-msg-time {
opacity: 0;
pointer-events: none;
}
/* 操作按鈕:調整為覆蓋時間戳記的位置 */
.lm-nav-fav-manager-actions {
position: absolute;
right: var(--space-2);
top: 50%;
transform: translateY(-50%);
display: flex;
gap: 2px;
opacity: 0;
transition: opacity var(--duration-fast);
background: var(--lm-nav-bg); /* 確保覆蓋時間戳記 */
padding-left: var(--space-1);
}
/* 確保列表項是相對定位 */
.lm-nav-fav-manager-item {
position: relative;
}
/* === Onboarding === */
.lm-nav-onboarding { position: fixed; z-index: ${CONFIG.Z_INDEX.TOAST}; opacity: 0; transform: translateX(8px); transition: all var(--duration-slow) var(--ease-out); }
.lm-nav-onboarding--visible { opacity: 1; transform: translateX(0); }
.lm-nav-onboarding--hiding { opacity: 0; transform: translateX(8px); }
.lm-nav-onboarding-content { width: 280px; padding: var(--space-4); background: var(--lm-nav-bg); border: 1px solid var(--lm-nav-border); border-radius: var(--radius-lg); box-shadow: var(--shadow-lg); }
.lm-nav-onboarding-header { display: flex; align-items: center; gap: var(--space-2); margin-bottom: var(--space-3); }
.lm-nav-onboarding-icon { font-size: 20px; }
.lm-nav-onboarding-title { font-size: 15px; font-weight: 700; color: var(--lm-nav-text); }
.lm-nav-onboarding-list { margin: 0 0 var(--space-4); padding-left: 20px; font-size: var(--lm-nav-font-size); color: var(--lm-nav-text-secondary); line-height: 1.8; }
.lm-nav-onboarding-close { width: 100%; padding: var(--space-2); border: none; border-radius: var(--radius-sm); background: var(--lm-nav-accent); color: #fff; font-size: var(--lm-nav-font-size); font-weight: 700; cursor: pointer; }
.lm-nav-onboarding-close:hover { background: var(--lm-nav-accent-hover); }
/* === Jiggle Animation === */
@keyframes lm-nav-jiggle {
0%, 100% { transform: translateX(0); }
20%, 60% { transform: translateX(-3px); }
40%, 80% { transform: translateX(3px); }
}
.${CONFIG.JIGGLE_CLASS} {
animation: lm-nav-jiggle ${CONFIG.JIGGLE_DURATION}ms ease-in-out;
outline: 2px solid var(--lm-nav-accent) !important;
outline-offset: 3px !important;
}
/* === Scrollbar === */
.lm-nav-list::-webkit-scrollbar,
.lm-nav-favorites-list::-webkit-scrollbar,
.lm-nav-dialog-content::-webkit-scrollbar { width: 6px; }
.lm-nav-list::-webkit-scrollbar-thumb,
.lm-nav-favorites-list::-webkit-scrollbar-thumb,
.lm-nav-dialog-content::-webkit-scrollbar-thumb { background: var(--lm-nav-border); border-radius: 3px; }
.lm-nav-list::-webkit-scrollbar-thumb:hover,
.lm-nav-favorites-list::-webkit-scrollbar-thumb:hover { background: var(--lm-nav-text-tertiary); }
/* === Responsive === */
@media (max-width: 480px) {
.lm-nav-panel { width: calc(100vw - 24px); max-width: 380px; }
.lm-nav-dialog { margin: 12px; }
.lm-nav-setting-row--pair { grid-template-columns: 1fr; }
}
/* === Reduced Motion === */
@media (prefers-reduced-motion: reduce) {
.lm-nav-fab, .lm-nav-indicator, .lm-nav-panel, .lm-nav-toast, .lm-nav-dialog, .lm-nav-onboarding,
.lm-nav-item, .lm-nav-pulse { transition-duration: 0.01ms !important; }
.${CONFIG.JIGGLE_CLASS} { animation: none !important; }
}
`;
}
}
// ========================================
// Initialization
// ========================================
const app = new ChatNavigator();
app.init();
})();