dlsdgj_Obsidian-SwiftMatch/main.js
2026-07-14 18:50:55 +08:00

9330 lines
372 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const { Plugin, WorkspaceLeaf, MarkdownRenderer, Notice } = require('obsidian');
const { Decoration, EditorView } = require('@codemirror/view');
const { StateField, StateEffect } = require('@codemirror/state');
const setMatchHighlights = StateEffect.define();
const matchHighlightField = StateField.define({
create() {
return Decoration.none;
},
update(decorations, tr) {
decorations = decorations.map(tr.changes);
for (const e of tr.effects) {
if (e.is(setMatchHighlights)) {
decorations = e.value;
}
}
return decorations;
},
provide: field => EditorView.decorations.from(field)
});
const setJumpHighlight = StateEffect.define();
const jumpHighlightField = StateField.define({
create() {
return Decoration.none;
},
update(decorations, tr) {
for (const e of tr.effects) {
if (e.is(setJumpHighlight)) {
decorations = e.value;
}
}
return decorations.map(tr.changes);
},
provide: field => EditorView.decorations.from(field)
});
class MatchHighlightPlugin {
constructor(view) {
this.view = view;
}
update() {}
}
const DEFAULT_SETTINGS = {
top: 40,
width: 2,
maxWidth: 80,
heightOffset: 65,
opacity: 1.0,
editorPadding: 10,
sliderColor: '#F0B27F',
highlightColor: '#ffc800',
highlightCurrentColor: '#ff3232',
contentOpacity: 1.0,
collapseOnHover: false,
collapseWidth: 8,
matchBorderColor: '#ff6600',
matchOpacity: 0.15,
counterColor: '#aba6a6',
counterBgColor: '#ff6600',
counterSize: 9,
counterTopOffset: -10,
enableSelectionMatch: true,
exhaustiveMode: true,
matchListWidth: 280,
matchListHeight: 400,
floatingToggleX: 50,
floatingToggleY: 100,
floatingToggleSize: 20,
floatingToggleVisible: true,
floatingToggleOpacity: 0.6,
hideStatusBar: false,
rightOffset: 0,
pinIconEnabled: true,
pinIconMode: 'follow',
pinIconSize: 12,
pinIconOpacity: 0.7,
pinIconFixedLeft: null,
pinIconFixedTop: null,
pinIconFollowOffsetX: 20,
pinIconFollowOffsetY: -20,
pinColorSchemes: [
{ borderColor: '#ff6600', counterBgColor: '#ff6600', counterColor: '#aba6a6', borderLinked: true, counterLinked: true },
{ borderColor: '#2196F3', counterBgColor: '#2196F3', counterColor: '#aba6a6', borderLinked: true, counterLinked: true },
{ borderColor: '#4CAF50', counterBgColor: '#4CAF50', counterColor: '#aba6a6', borderLinked: true, counterLinked: true },
{ borderColor: '#9C27B0', counterBgColor: '#9C27B0', counterColor: '#aba6a6', borderLinked: true, counterLinked: true },
{ borderColor: '#FF9800', counterBgColor: '#FF9800', counterColor: '#aba6a6', borderLinked: true, counterLinked: true },
{ borderColor: '#E91E63', counterBgColor: '#E91E63', counterColor: '#aba6a6', borderLinked: true, counterLinked: true }
],
simplifiedColorScheme: true,
clearCountOnClose: false,
floatingToggleText: 'Swift',
floatingToggleFontSize: 16,
floatingTogglePaddingH: 10,
floatingTogglePaddingV: 2,
floatingToggleStyleClass: 'candy-mark',
floatingToggleCustomStyle: '.candy-mark { background: linear-gradient(120deg, #ffebcc 0%, #ffd9a5 100%); color: #b45f2b; padding: 2px 6px; border-radius: 12px; font-weight: 500; font-size: 0.9em; display: inline-block; }',
keywordButtonStyles: {},
minimapBlacklist: '*.canvas',
searchWordCountMin: 0,
searchWordCountMax: 0,
language: 'zh',
counterStylePreset: 'glass',
multiKeywordRequireAll: true,
searchOnInput: false,
outlineOnHover: false,
outlinePinnedMargin: true
};
const I18N = {
zh: {
settingsTitle: 'SwiftMatch 设置',
tabBasic: '基础',
tabCounter: '计数样式',
tabPinned: '已固定',
topDistance: '顶部距离 (px)',
width: '宽度 (%)',
maxWidth: '最大宽度 (px)',
bottomOffset: '底部偏移 (px)',
rightOffset: '右边偏移 (px)',
sliderColor: '滑块颜色',
opacity: '透明度',
contentOpacity: '内容透明度',
hoverExpand: '悬停展开',
collapseWidth: '折叠宽度 (px)',
floatingToggleText: '悬浮球显示文字',
showFloating: '悬浮显示',
toggleFloatingBtn: '显示/隐藏悬浮按钮',
floatingToggleStyle: '悬浮按钮样式',
minimap: 'minimap',
minimapBlacklist: 'minimap 黑名单',
minimapBlacklistDesc: '匹配路径的页面不显示 minimap每行一个支持 * 通配符)',
fontSize: '文字大小 (px)',
paddingH: '水平内边距 (px)',
paddingV: '垂直内边距 (px)',
hideStatusBar: '隐藏底部状态栏',
searchLimit: '搜索限制',
minWords: '最少字数 (0=不限)',
maxWords: '最多字数 (0=不限)',
pinIcon: '固定图标',
enablePinIcon: '启用固定图标',
iconMode: '图标模式',
followMouse: '跟随鼠标',
fixedPosition: '固定位置',
followOffsetX: '水平偏移 (px)',
followOffsetY: '垂直偏移 (px)',
iconSize: '图标大小 (px)',
iconOpacity: '图标透明度',
iconText: '图标文字',
colorSchemes: '配色方案 (按顺序使用)',
simplifiedColor: '简化配色',
addColor: '+ 添加配色',
counterStyle: '计数样式',
matchOpacity: '匹配透明度',
counterOpacity: '计数透明度',
counterBgOpacity: '计数背景透明度',
counterFontSize: '计数字体大小',
counterPaddingH: '计数水平内边距 (px)',
counterPaddingV: '计数垂直内边距 (px)',
counterTopOffset: '计数上偏移 (px)',
counterStylePreset: '计数样式预设',
counterPresetGlass: '毛玻璃',
counterPresetOutlined: '描边气泡',
multiKeywordRequireAll: '仅显示包含所有关键词的文档',
searchOnInput: '输入即搜',
outlineOnHover: '悬浮显示大纲',
outlineOnHoverDesc: '鼠标悬浮到 minimap 时显示文档大纲,高亮当前所在标题',
outlinePinnedMargin: '固定时调整正文边距',
clearCountOnClose: '关闭文档清除计数',
pinnedMatches: '已固定匹配项',
clearAllCounts: '清除所有计数',
rebuildCache: '重建标题缓存',
resetDefault: '重置默认',
about: '关于/更新',
building: '正在构建...',
border: '边框',
underline: '下划线',
counterBg: '计数背景',
counterTextColor: '计数文字',
bubbleBorder: '气泡边框',
syncColors: '同步下划线与背景色',
background: '背景',
text: '文字',
noPinned: '暂无固定匹配项',
unknownFile: '未知文件',
clearDocPinned: '清除该文档所有固定项',
clear: '清除',
remove: '删除',
searchPlaceholder: '搜索... (竖线分隔多关键词: 鸽子|导航|磁场)',
unpin: '取消固定',
clearPinned: (n) => `清除${n}个固定项`,
pinMatch: '固定此匹配',
recentSearch: '最近搜索',
favoriteSearch: '收藏',
addFavorite: '收藏',
removeFavorite: '取消收藏',
floatThisWord: '悬浮此词',
removeFloat: '移除悬浮此词',
exhaustiveMatch: (n, m) => `全库匹配: ${n} 个文档 (${m} 项结果)`,
libraryMatch: (n, m) => `库中匹配: ${n} 个文档 (${m} 项结果)`,
save: '保存',
exportMarkdown: '导出匹配结果为 Markdown',
copy: '复制',
openDoc: '打开文档',
exported: (f) => `已导出: ${f}`,
exportFailed: (e) => `导出失败: ${e}`,
sourceNotFound: (f) => `找不到源文档: ${f}`,
editFloating: '编辑',
hideFloating: '隐藏',
closeKeyword: '关闭',
settings: '设置',
editTitle: '编辑悬浮按钮',
toggleText: '显示文字',
toggleFontSize: '字体大小',
togglePaddingH: '水平内边距',
togglePaddingV: '垂直内边距',
toggleOpacity: '透明度',
toggleSize: '按钮大小',
styleClass: '样式类名',
customStyle: '自定义样式',
getMoreStyles: '(获取更多样式)',
styleHint: '支持完整CSS格式含伪元素。类名会自动作用域化。',
preview: '预览',
styleName: '样式名称',
addStyle: '+ 添加样式',
deleteStyle: '删除',
setDefault: '默认',
defaultStyle: '默认',
noStyles: '暂无自定义样式,请使用 .className { } 格式定义',
clickToSelect: '点击选择',
confirm: '确认',
cancel: '取消',
close: '关闭',
expandBefore: '展开前文',
expandAfter: '展开后文'
},
en: {
settingsTitle: 'SwiftMatch Settings',
tabBasic: 'Basic',
tabCounter: 'Counter Style',
tabPinned: 'Pinned',
topDistance: 'Top Distance (px)',
width: 'Width (%)',
maxWidth: 'Max Width (px)',
bottomOffset: 'Bottom Offset (px)',
rightOffset: 'Right Offset (px)',
sliderColor: 'Slider Color',
opacity: 'Opacity',
contentOpacity: 'Content Opacity',
hoverExpand: 'Hover Expand',
collapseWidth: 'Collapse Width (px)',
floatingToggleText: 'Floating Toggle Text',
showFloating: 'Show Floating',
toggleFloatingBtn: 'Show/Hide Floating Button',
floatingToggleStyle: 'Floating Button Style',
minimap: 'minimap',
minimapBlacklist: 'Minimap Blacklist',
minimapBlacklistDesc: 'Pages matching these paths will not show minimap (one per line, * wildcard supported)',
fontSize: 'Font Size (px)',
paddingH: 'Horizontal Padding (px)',
paddingV: 'Vertical Padding (px)',
hideStatusBar: 'Hide Status Bar',
searchLimit: 'Search Limits',
minWords: 'Min Words (0=no limit; CJK counts by char)',
maxWords: 'Max Words (0=no limit; CJK counts by char)',
pinIcon: 'Pin Icon',
enablePinIcon: 'Enable Pin Icon',
iconMode: 'Icon Mode',
followMouse: 'Follow Mouse',
fixedPosition: 'Fixed Position',
followOffsetX: 'Horizontal Offset (px)',
followOffsetY: 'Vertical Offset (px)',
iconSize: 'Icon Size (px)',
iconOpacity: 'Icon Opacity',
iconText: 'Icon Text',
colorSchemes: 'Color Schemes (used in order)',
simplifiedColor: 'Simplified Colors',
addColor: '+ Add Color',
counterStyle: 'Counter Style',
matchOpacity: 'Match Opacity',
counterOpacity: 'Counter Opacity',
counterBgOpacity: 'Counter BG Opacity',
counterFontSize: 'Counter Font Size',
counterPaddingH: 'Counter H Padding (px)',
counterPaddingV: 'Counter V Padding (px)',
counterTopOffset: 'Counter Top Offset (px)',
counterStylePreset: 'Counter Style Preset',
counterPresetGlass: 'Glassmorphism',
counterPresetOutlined: 'Outlined Bubble',
multiKeywordRequireAll: 'Only show docs with all keywords',
searchOnInput: 'Search on input',
outlineOnHover: 'Show Outline on Hover',
outlineOnHoverDesc: 'Show document outline when hovering over minimap, highlight current heading',
outlinePinnedMargin: 'Adjust content margin when pinned',
clearCountOnClose: 'Clear Count on Close',
pinnedMatches: 'Pinned Matches',
clearAllCounts: 'Clear All Counts',
rebuildCache: 'Rebuild Heading Cache',
resetDefault: 'Reset Defaults',
about: 'About/Updates',
building: 'Building...',
border: 'Border',
underline: 'Underline',
counterBg: 'Counter BG',
counterTextColor: 'Counter Text',
bubbleBorder: 'Bubble Border',
syncColors: 'Sync underline & counter BG',
background: 'Background',
text: 'Text',
noPinned: 'No pinned matches',
unknownFile: 'Unknown file',
clearDocPinned: 'Clear all pinned items for this doc',
clear: 'Clear',
remove: 'Remove',
searchPlaceholder: 'Search... (pipe-separated: bird|navigation|magnet)',
unpin: 'Unpin',
clearPinned: (n) => `Clear ${n} pinned items`,
pinMatch: 'Pin this match',
recentSearch: 'Recent Searches',
favoriteSearch: 'Favorites',
addFavorite: 'Favorite',
removeFavorite: 'Unfavorite',
floatThisWord: 'Float this word',
removeFloat: 'Remove floating',
exhaustiveMatch: (n, m) => `Exhaustive: ${n} docs (${m} results)`,
libraryMatch: (n, m) => `Library: ${n} docs (${m} results)`,
save: 'Save',
exportMarkdown: 'Export results as Markdown',
copy: 'Copy',
openDoc: 'Open Document',
exported: (f) => `Exported: ${f}`,
exportFailed: (e) => `Export failed: ${e}`,
sourceNotFound: (f) => `Source not found: ${f}`,
editFloating: 'Edit',
hideFloating: 'Hide',
closeKeyword: 'Close',
settings: 'Settings',
editTitle: 'Edit Floating Toggle',
toggleText: 'Display Text',
toggleFontSize: 'Font Size',
togglePaddingH: 'Horizontal Padding',
togglePaddingV: 'Vertical Padding',
toggleOpacity: 'Opacity',
toggleSize: 'Button Size',
styleClass: 'Style Class',
customStyle: 'Custom Style',
getMoreStyles: '(Get More Styles)',
styleHint: 'Supports full CSS format including pseudo-elements. Class names are auto-scoped.',
preview: 'Preview',
styleName: 'Style Name',
addStyle: '+ Add Style',
deleteStyle: 'Delete',
setDefault: 'Default',
defaultStyle: 'Default',
noStyles: 'No custom styles detected, use .className { } format',
clickToSelect: 'Click to select',
confirm: 'Confirm',
cancel: 'Cancel',
close: 'Close',
expandBefore: 'Expand before',
expandAfter: 'Expand after'
}
};
function t(key, ...args) {
const lang = (typeof window !== 'undefined' && window._swiftMatchLang) || 'zh';
const val = I18N[lang]?.[key] ?? I18N.zh[key];
if (typeof val === 'function') return val(...args);
return val;
}
class MinimapPlugin extends Plugin {
constructor(app, manifest) {
super(app, manifest);
this.manifest = manifest;
this.minimapContainer = null;
this.minimapContent = null;
this.scrollbar = null;
this.isDraggingScrollbar = false;
this.scrollbarDragStartY = 0;
this.scrollbarDragStartScrollTop = 0;
this.slider = null;
this.isDragging = false;
this.isJumping = false;
this.dragStartY = 0;
this.dragStartLine = 0;
this.highlights = [];
this.editorHighlights = [];
this.decorationField = null;
this.currentSelection = '';
this.currentCursor = null;
this.updateTimer = null;
this.boundMouseMove = null;
this.boundMouseUp = null;
this.scrollHandler = null;
this.editorScrollEl = null;
this.opacity = 0.3;
this.boundWheel = null;
this.editorContentEl = null;
this.originalPaddingRight = '';
this.settings = Object.assign({}, DEFAULT_SETTINGS);
this.settingsPanel = null;
this.boundContextMenu = null;
this.tooltip = null;
this.matchList = null;
this.previewPanel = null;
this.isPreviewOpen = false;
this.originalListPosition = 'right';
this.isInteractingWithList = false;
this.currentPreviewFile = null;
this.currentPreviewHeading = null;
this._previewOutsideClickHandler = null;
this._listCloseHandler = null;
this._pendingListClose = false;
this.previewSourceLeaf = null;
this._searchGeneration = 0;
this._searchInProgress = false;
this._isListVisible = false;
this._listUserDismissed = false;
this._keepListVisible = false;
this._chipSwitching = false;
this._listPinnedSearchText = null;
this._pinnedWordFileMap = null;
this._pinnedWordMatchCount = 0;
this._pendingShowList = null;
this._triggeredByIndicator = false;
this._cachedMatchList = null;
this._cachedMatchListKey = null;
this.listPosition = { left: null, top: null };
this.listFixedPosition = { left: null, top: null };
this.previewListPosition = { left: null, top: null };
this.floatingStyles = [];
this.floatingDefaultStyleId = null;
this.listOpacity = 1.0;
this.previewOpacity = 1.0;
this.previewSize = { width: 600, height: 600 };
this.matchListSize = { width: null, height: null };
this.isDraggingList = false;
this.listDragStartX = 0;
this.listDragStartY = 0;
this.listDragStartLeft = 0;
this.listDragStartTop = 0;
this.boundListWheel = null;
this.listDataPath = null;
this.savedMatchLists = [];
this.matchListIndicator = null;
this.currentMatchListData = null;
this.matchListIndicators = [];
this._currentIndicatorSelection = null;
this._currentIndicatorMatchCount = null;
this.lastIndicatorCoords = null;
this.floatingToggle = null;
this._isApplyingReadingHighlights = false;
this._readingViewObserver = null;
this._readingHighlightRetryTimer = null;
this._pinIcon = null;
this._lastMouseX = 0;
this._lastMouseY = 0;
this._recentSearches = [];
this._recentSearchCaches = {};
this._expandedGroups = {};
this._recentSearchCachesData = []; // deprecated, kept for migration
this._favoriteSearches = [];
this._listScrollPositions = {}; // { term: scrollTop }
this._lastListSearchTerm = null; // search term used when list was last visible
this._lastListFileMap = null; // fileMap when list was last visible
this._lastListMatchCount = 0; // matchCount when list was last visible
this._floatingKeywordButtons = []; // [{ wrapper, term, fileMap, matchCount, position }]
this._floatingKeywordsData = []; // deprecated, kept for migration
this._listTriggerElement = null; // element that triggered the list display (keyword btn or floating toggle)
}
onload() {
this.loadSettings();
this.loadListData();
this.loadFloatingStyleData();
this.opacity = this.settings.opacity;
this.addCommands();
this.boundMouseMove = this.handleMouseMove.bind(this);
this.boundMouseUp = this.handleMouseUp.bind(this);
this.boundWheel = this.handleWheel.bind(this);
this.boundContextMenu = this.showSettingsPanel.bind(this);
this.boundListWheel = this.handleListWheel.bind(this);
document.addEventListener('mousemove', this.boundMouseMove);
document.addEventListener('mouseup', this.boundMouseUp);
// Watch for theme changes to refresh counter colors
this._lastThemeDark = document.body.classList.contains('theme-dark');
this._themeObserver = new MutationObserver(() => {
const isDark = document.body.classList.contains('theme-dark');
if (isDark !== this._lastThemeDark) {
this._lastThemeDark = isDark;
const preset = this.settings.counterStylePreset || 'glass';
if (preset === 'glass') {
this.clearHighlights();
this.showPinnedDecorations();
if (this.currentSelection) this.highlightMatches();
}
}
});
this._themeObserver.observe(document.body, { attributes: true, attributeFilter: ['class'] });
this.registerEvents();
setTimeout(async () => {
this.setupMinimap();
if (this.settings.floatingToggleVisible) {
this.createFloatingToggle();
}
await this._restoreRecentSearchCaches();
await this._restoreFloatingKeywordButtons();
}, 300);
}
loadSettings() {
const saved = localStorage.getItem('swift-match-settings');
if (saved) {
try {
const parsed = JSON.parse(saved);
this.settings = Object.assign({}, DEFAULT_SETTINGS, parsed);
this.settings.exhaustiveMode = true;
// Migrate legacy default (2/7) which blocked all CJK single-phrase searches.
// Now countWords() handles CJK by character count, but the old 2/7 values
// were a bug default — reset to 0/0 (no limit) so users start clean.
if (this.settings.searchWordCountMin === 2 && this.settings.searchWordCountMax === 7) {
this.settings.searchWordCountMin = 0;
this.settings.searchWordCountMax = 0;
this.saveSettings();
}
if (!this.settings.pinColorSchemes || !Array.isArray(this.settings.pinColorSchemes) || this.settings.pinColorSchemes.length === 0) {
this.settings.pinColorSchemes = DEFAULT_SETTINGS.pinColorSchemes.map(s => ({...s}));
}
} catch (e) {
this.settings = Object.assign({}, DEFAULT_SETTINGS);
}
}
window._swiftMatchLang = this.settings.language || 'zh';
}
saveSettings() {
localStorage.setItem('swift-match-settings', JSON.stringify(this.settings));
}
async loadListData() {
const dataDir = this.app.vault.configDir || '.obsidian';
this.listDataPath = `${dataDir}/plugins/swift-match/list-data.json`;
this.cacheDir = `${dataDir}/plugins/swift-match/cache`;
this.keywordDir = `${dataDir}/plugins/swift-match/keywords`;
try {
const adapter = this.app.vault.adapter;
if (await adapter.exists(this.listDataPath)) {
const content = await adapter.read(this.listDataPath);
const data = JSON.parse(content);
this.listPosition = data.listPosition || { left: null, top: null };
this.listFixedPosition = data.listFixedPosition || { left: null, top: null };
this.previewListPosition = data.previewListPosition || { left: null, top: null };
this.listOpacity = data.listOpacity ?? 1.0;
this.previewOpacity = data.previewOpacity ?? 1.0;
this.previewSize = data.previewSize || { width: 600, height: 600 };
this.matchListSize = data.matchListSize || { width: null, height: null };
this.savedMatchLists = data.savedMatchLists || [];
this._matchListScrollTop = data.matchListScrollTop || 0;
this._recentSearches = data.recentSearches || [];
this._favoriteSearches = data.favoriteSearches || [];
this._listScrollPositions = Object.fromEntries(
Object.entries(data.listScrollPositions || {}).filter(([k]) =>
this._favoriteSearches.includes(k) || this._recentSearches.includes(k)
)
);
}
} catch (e) {
console.error('Failed to load list data:', e);
this.listPosition = { left: null, top: null };
this.listFixedPosition = { left: null, top: null };
this.previewListPosition = { left: null, top: null };
this.listOpacity = 1.0;
this.previewOpacity = 1.0;
this.previewSize = { width: 600, height: 600 };
this.matchListSize = { width: null, height: null };
this.savedMatchLists = [];
}
}
_cacheKeyToFileName(term) {
return term.replace(/[\\/:*?"<>|]/g, '_').replace(/\s+/g, '_');
}
async saveSearchCache(term, fileMap, matchCount) {
const adapter = this.app.vault.adapter;
if (!await adapter.exists(this.cacheDir)) {
await adapter.mkdir(this.cacheDir);
}
const fileName = this._cacheKeyToFileName(term);
const filePath = `${this.cacheDir}/${fileName}.json`;
const fileMapData = Array.from(fileMap.entries()).map(([file, headings]) => ({
path: file.path,
basename: file.basename,
headings
}));
await adapter.write(filePath, JSON.stringify({ term, matchCount, fileMapData }, null, 2));
}
async loadSearchCache(term) {
const adapter = this.app.vault.adapter;
const fileName = this._cacheKeyToFileName(term);
const filePath = `${this.cacheDir}/${fileName}.json`;
if (!await adapter.exists(filePath)) return null;
try {
const content = await adapter.read(filePath);
const data = JSON.parse(content);
const fileMap = await this.deserializeFileMap(data.fileMapData || []);
return { fileMap, matchCount: data.matchCount || 0 };
} catch (e) {
return null;
}
}
async deleteSearchCache(term) {
const adapter = this.app.vault.adapter;
const fileName = this._cacheKeyToFileName(term);
const filePath = `${this.cacheDir}/${fileName}.json`;
if (await adapter.exists(filePath)) {
try { await adapter.remove(filePath); } catch (e) {}
}
}
async saveKeywordData(term, position, fileMap, matchCount) {
const adapter = this.app.vault.adapter;
if (!await adapter.exists(this.keywordDir)) {
await adapter.mkdir(this.keywordDir);
}
const fileName = this._cacheKeyToFileName(term);
const filePath = `${this.keywordDir}/${fileName}.json`;
const fileMapData = fileMap ? Array.from(fileMap.entries()).map(([file, headings]) => ({
path: file.path,
basename: file.basename,
headings
})) : [];
await adapter.write(filePath, JSON.stringify({ term, position, matchCount, fileMapData }, null, 2));
}
async loadAllKeywordData() {
const adapter = this.app.vault.adapter;
if (!await adapter.exists(this.keywordDir)) return [];
const result = [];
try {
const listed = await adapter.list(this.keywordDir);
const files = Array.isArray(listed) ? listed : (listed?.files || []);
for (const f of files) {
const filePath = f.startsWith(this.keywordDir) ? f : `${this.keywordDir}/${f}`;
if (!filePath.endsWith('.json')) continue;
try {
const content = await adapter.read(filePath);
result.push(JSON.parse(content));
} catch (e) {}
}
} catch (e) {}
return result;
}
async deleteKeywordData(term) {
const adapter = this.app.vault.adapter;
const fileName = this._cacheKeyToFileName(term);
const filePath = `${this.keywordDir}/${fileName}.json`;
if (await adapter.exists(filePath)) {
await adapter.remove(filePath);
}
}
async saveListData() {
if (!this.listDataPath) return;
try {
const adapter = this.app.vault.adapter;
const data = {
listPosition: this.listPosition,
listFixedPosition: this.listFixedPosition,
previewListPosition: this.previewListPosition,
listOpacity: this.listOpacity,
previewOpacity: this.previewOpacity,
previewSize: this.previewSize,
matchListSize: this.matchListSize,
savedMatchLists: this.savedMatchLists,
matchListScrollTop: this._matchListScrollTop || 0,
recentSearches: this._recentSearches || [],
favoriteSearches: this._favoriteSearches || [],
listScrollPositions: Object.fromEntries(
Object.entries(this._listScrollPositions || {}).filter(([k]) =>
this._favoriteSearches.includes(k) || this._recentSearches.includes(k)
)
)
};
await adapter.write(this.listDataPath, JSON.stringify(data, null, 2));
for (const [term, cache] of Object.entries(this._recentSearchCaches)) {
if (cache && cache.fileMap && cache.fileMap.size > 0) {
await this.saveSearchCache(term, cache.fileMap, cache.matchCount || 0);
}
}
} catch (e) {
console.error('Failed to save list data:', e);
}
}
async loadFloatingStyleData() {
const dataDir = this.app.vault.configDir || '.obsidian';
this.floatingStyleDataPath = `${dataDir}/plugins/swift-match/data.json`;
try {
const adapter = this.app.vault.adapter;
if (await adapter.exists(this.floatingStyleDataPath)) {
const content = await adapter.read(this.floatingStyleDataPath);
const data = JSON.parse(content);
this.floatingStyles = data.floatingStyles || [];
this.floatingDefaultStyleId = data.floatingDefaultStyleId || null;
} else {
this.floatingStyles = [];
this.floatingDefaultStyleId = null;
}
// Migrate old single customStyle to new multi-style format
if (this.floatingStyles.length === 0 && this.settings.floatingToggleCustomStyle) {
this.floatingStyles.push({
id: Date.now().toString(),
name: this.settings.floatingToggleText || 'Swift',
styleClass: this.settings.floatingToggleStyleClass || '',
customStyle: this.settings.floatingToggleCustomStyle || ''
});
this.floatingDefaultStyleId = this.floatingStyles[0].id;
await this.saveFloatingStyleData();
}
} catch (e) {
console.error('Failed to load floating style data:', e);
this.floatingStyles = [];
this.floatingDefaultStyleId = null;
}
}
async saveFloatingStyleData() {
if (!this.floatingStyleDataPath) return;
try {
const adapter = this.app.vault.adapter;
const data = {
floatingStyles: this.floatingStyles,
floatingDefaultStyleId: this.floatingDefaultStyleId
};
await adapter.write(this.floatingStyleDataPath, JSON.stringify(data, null, 2));
} catch (e) {
console.error('Failed to save floating style data:', e);
}
}
getActiveFloatingStyle() {
if (!this.floatingDefaultStyleId || !this.floatingStyles || this.floatingStyles.length === 0) {
return null;
}
return this.floatingStyles.find(s => s.id === this.floatingDefaultStyleId) || null;
}
applySettings() {
if (!this.minimapContainer) return;
const s = this.settings;
this.minimapContainer.style.top = `${s.top}px`;
this.minimapContainer.style.right = `${s.rightOffset}px`;
this.minimapContainer.style.width = `${s.width}%`;
this.minimapContainer.style.maxWidth = `${s.maxWidth}px`;
this.minimapContainer.style.height = `calc(100vh - ${s.top}px - ${s.heightOffset}px)`;
this.minimapContainer.style.opacity = s.opacity;
this.opacity = s.opacity;
if (this.slider) {
this.slider.style.backgroundColor = s.sliderColor;
}
if (this.minimapContent) {
this.minimapContent.style.opacity = s.contentOpacity;
}
if (this.minimapContainer) {
if (s.collapseOnHover) {
this.minimapContainer.classList.add('collapsed');
this.minimapContainer.style.setProperty('--collapse-width', `${s.collapseWidth}px`);
} else {
this.minimapContainer.classList.remove('collapsed');
}
}
this.removeEditorPadding();
this.applyEditorPadding();
this.updateViewport();
this.applyHideStatusBar();
}
applyCounterStyles() {
const s = this.settings;
const counterSize = s.counterSize;
const counterPadding = '0px 2px';
const counterTopOffset = `${s.counterTopOffset}px`;
// 更新阅读模式
const readingEl = document.querySelector('.markdown-reading-view');
if (readingEl) {
readingEl.style.setProperty('--minimap-counter-opacity', 1);
readingEl.style.setProperty('--minimap-counter-size', `${counterSize}px`);
readingEl.style.setProperty('--minimap-counter-padding', counterPadding);
readingEl.style.setProperty('--minimap-counter-top-offset', counterTopOffset);
}
// 更新编辑器模式
const editor = this.getEditor();
if (editor && editor.cm && editor.cm.scrollDOM) {
const scrollDOM = editor.cm.scrollDOM;
scrollDOM.style.setProperty('--minimap-counter-opacity', 1);
scrollDOM.style.setProperty('--minimap-counter-size', `${counterSize}px`);
scrollDOM.style.setProperty('--minimap-counter-padding', counterPadding);
scrollDOM.style.setProperty('--minimap-counter-top-offset', counterTopOffset);
// 背景透明度也需要更新(颜色从配色方案获取)
const currentPreset = s.counterStylePreset || 'glass';
const counterColor = currentPreset === 'glass'
? (document.body.classList.contains('theme-dark') ? '#e0dcdc' : '#aba6a6')
: s.counterColor;
const counterBgColor = s.counterBgColor;
scrollDOM.style.setProperty('--minimap-counter-color', counterColor);
scrollDOM.style.setProperty('--minimap-counter-bgcolor', currentPreset === 'outlined' ? counterBgColor : this.hexToRgba(counterBgColor, 1));
if (readingEl) {
readingEl.style.setProperty('--minimap-counter-color', counterColor);
readingEl.style.setProperty('--minimap-counter-bgcolor', currentPreset === 'outlined' ? counterBgColor : this.hexToRgba(counterBgColor, 1));
}
}
}
initCollapsibleSections() {
if (!this.settingsPanel) return;
const titles = this.settingsPanel.querySelectorAll('.minimap-settings-section-title');
titles.forEach(title => {
title.style.cursor = 'pointer';
title.style.userSelect = 'none';
// 添加折叠箭头
if (!title.querySelector('.collapse-arrow')) {
const arrow = document.createElement('span');
arrow.className = 'collapse-arrow';
arrow.textContent = ' ▾';
arrow.style.cssText = 'font-size:10px;float:right;transition:transform 0.2s;';
title.appendChild(arrow);
}
// 收集后续兄弟元素直到下一个 section title
const siblings = [];
let next = title.nextElementSibling;
while (next && !next.classList.contains('minimap-settings-section-title') && !next.classList.contains('minimap-settings-footer')) {
siblings.push(next);
next = next.nextElementSibling;
}
// 包裹到容器中
const wrapper = document.createElement('div');
wrapper.className = 'minimap-section-content';
siblings.forEach(el => wrapper.appendChild(el));
title.parentNode.insertBefore(wrapper, title.nextSibling);
// 点击折叠/展开
title.addEventListener('click', () => {
const isCollapsed = wrapper.style.display === 'none';
wrapper.style.display = isCollapsed ? '' : 'none';
const arrow = title.querySelector('.collapse-arrow');
if (arrow) arrow.style.transform = isCollapsed ? '' : 'rotate(-90deg)';
});
});
}
applyCounterVisibility() {
this.clearHighlights();
if (this.currentSelection) {
this.highlightMatches();
} else {
const isReading = this.isReadingMode();
if (isReading) {
this.showPinnedDecorations();
} else {
const editor = this.getEditor();
if (editor && editor.cm) {
this.addEditorDecorationsWithPinned(editor.cm, [], 0);
}
}
}
}
showSettingsPanel(e) {
e.preventDefault();
e.stopPropagation();
if (this.settingsPanel) {
this.closeSettingsPanel();
return;
}
this.settingsPanel = document.createElement('div');
this.settingsPanel.className = 'minimap-settings-panel';
this.settingsPanel.innerHTML = `
<div class="minimap-settings-header">
<div style="display:flex;align-items:center;gap:8px;">
<span id="minimap-lang-toggle" style="cursor:pointer;user-select:none;font-size:10px;font-weight:600;${this.settings.language === 'zh' ? 'color:#D85A30;' : 'color:#185FA5;'}letter-spacing:${this.settings.language === 'zh' ? '.1em' : '.08em'};">${this.settings.language === 'zh' ? 'CN' : 'EN'}</span>
<span>${t('settingsTitle')} v${this.manifest.version || '1.0.0'}</span>
</div>
<button class="minimap-settings-close">&times;</button>
</div>
<div class="minimap-settings-tabs">
<div class="minimap-settings-tab active" data-tab="basic">${t('tabBasic')}</div>
<div class="minimap-settings-tab" data-tab="counter">${t('tabCounter')}</div>
<div class="minimap-settings-tab" data-tab="pinned">${t('tabPinned')}</div>
</div>
<div class="minimap-settings-content">
<div class="minimap-settings-tab-content active" data-tab="basic">
<div class="minimap-settings-section-title">${t('minimap')}</div>
<div class="minimap-settings-row" style="flex-direction:column;align-items:stretch;">
<label>${t('minimapBlacklist')}<span style="font-weight:normal;font-size:11px;color:var(--text-muted);display:block;margin-top:2px;">${t('minimapBlacklistDesc')}</span></label>
<textarea id="minimap-blacklist" rows="3" style="width:100%;margin-top:4px;font-size:12px;resize:vertical;background:var(--background-modifier-form-field);color:var(--text-normal);border:1px solid var(--background-modifier-border);border-radius:4px;padding:4px 6px;">${this.settings.minimapBlacklist || ''}</textarea>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px;">
<div style="display:flex;align-items:center;gap:4px;">
<label style="white-space:nowrap;font-size:12px;color:var(--text-normal);">${t('topDistance')}</label>
<input type="number" id="minimap-top" value="${this.settings.top}" min="0" max="500" style="width:60px;padding:4px 6px;border:1px solid var(--background-modifier-border);border-radius:4px;background:var(--background-primary);color:var(--text-normal);font-size:12px;">
</div>
<div style="display:flex;align-items:center;gap:4px;">
<label style="white-space:nowrap;font-size:12px;color:var(--text-normal);">${t('width')}</label>
<input type="number" id="minimap-width" value="${this.settings.width}" min="1" max="20" step="0.5" style="width:60px;padding:4px 6px;border:1px solid var(--background-modifier-border);border-radius:4px;background:var(--background-primary);color:var(--text-normal);font-size:12px;">
</div>
<div style="display:flex;align-items:center;gap:4px;">
<label style="white-space:nowrap;font-size:12px;color:var(--text-normal);">${t('maxWidth')}</label>
<input type="number" id="minimap-maxwidth" value="${this.settings.maxWidth}" min="20" max="200" style="width:60px;padding:4px 6px;border:1px solid var(--background-modifier-border);border-radius:4px;background:var(--background-primary);color:var(--text-normal);font-size:12px;">
</div>
<div style="display:flex;align-items:center;gap:4px;">
<label style="white-space:nowrap;font-size:12px;color:var(--text-normal);">${t('bottomOffset')}</label>
<input type="number" id="minimap-heightoffset" value="${this.settings.heightOffset}" min="0" max="200" style="width:60px;padding:4px 6px;border:1px solid var(--background-modifier-border);border-radius:4px;background:var(--background-primary);color:var(--text-normal);font-size:12px;">
</div>
<div style="display:flex;align-items:center;gap:4px;">
<label style="white-space:nowrap;font-size:12px;color:var(--text-normal);">${t('rightOffset')}</label>
<input type="number" id="minimap-rightoffset" value="${this.settings.rightOffset}" min="-100" max="200" style="width:60px;padding:4px 6px;border:1px solid var(--background-modifier-border);border-radius:4px;background:var(--background-primary);color:var(--text-normal);font-size:12px;">
</div>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px;align-items:center;">
<div style="display:flex;align-items:center;gap:4px;">
<label style="white-space:nowrap;font-size:12px;color:var(--text-normal);">${t('sliderColor')}</label>
<input type="color" id="minimap-slidercolor" value="${this.settings.sliderColor}" style="width:36px;height:26px;padding:0;border:1px solid var(--background-modifier-border);border-radius:4px;cursor:pointer;">
</div>
<div style="display:flex;align-items:center;gap:4px;">
<label style="white-space:nowrap;font-size:12px;color:var(--text-normal);">${t('opacity')}</label>
<input type="range" id="minimap-opacity" value="${this.settings.opacity}" min="0.1" max="1" step="0.1" style="width:70px;">
<span id="minimap-opacity-value" style="font-size:11px;min-width:24px;">${this.settings.opacity}</span>
</div>
<div style="display:flex;align-items:center;gap:4px;">
<label style="white-space:nowrap;font-size:12px;color:var(--text-normal);">${t('contentOpacity')}</label>
<input type="range" id="minimap-contentopacity" value="${this.settings.contentOpacity}" min="0.1" max="1" step="0.1" style="width:70px;">
<span id="minimap-contentopacity-value" style="font-size:11px;min-width:24px;">${this.settings.contentOpacity}</span>
</div>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px;align-items:center;">
<div style="display:flex;align-items:center;gap:4px;">
<label style="white-space:nowrap;font-size:12px;color:var(--text-normal);">${t('hoverExpand')}</label>
<input type="checkbox" id="minimap-collapseonhover" ${this.settings.collapseOnHover ? 'checked' : ''}>
</div>
<div style="display:flex;align-items:center;gap:4px;">
<label style="white-space:nowrap;font-size:12px;color:var(--text-normal);">${t('collapseWidth')}</label>
<input type="number" id="minimap-collapsewidth" value="${this.settings.collapseWidth}" min="4" max="50" style="width:60px;padding:4px 6px;border:1px solid var(--background-modifier-border);border-radius:4px;background:var(--background-primary);color:var(--text-normal);font-size:12px;">
</div>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px;align-items:center;">
<div style="display:flex;align-items:center;gap:4px;">
<label style="white-space:nowrap;font-size:12px;color:var(--text-normal);">${t('outlineOnHover')}<span style="font-weight:normal;font-size:11px;color:var(--text-muted);display:block;margin-top:2px;">${t('outlineOnHoverDesc')}</span></label>
<input type="checkbox" id="minimap-outlineonhover" ${this.settings.outlineOnHover ? 'checked' : ''}>
</div>
<div style="display:flex;align-items:center;gap:4px;">
<label style="white-space:nowrap;font-size:12px;color:var(--text-normal);">${t('outlinePinnedMargin')}</label>
<input type="checkbox" id="minimap-outlinepinnedmargin" ${this.settings.outlinePinnedMargin ? 'checked' : ''}>
</div>
</div>
<div class="minimap-settings-section-title">${t('floatingToggleStyle')}</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px;align-items:center;">
<div style="display:flex;align-items:center;gap:4px;">
<label style="white-space:nowrap;font-size:12px;color:var(--text-normal);">${t('toggleFloatingBtn')}</label>
<span id="minimap-showfloatingtoggle" style="cursor:pointer;display:inline-block;">${this.settings.floatingToggleText || 'Swift'}</span>
</div>
</div>
<div class="minimap-settings-section-title">${t('searchLimit')}</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px;align-items:center;">
<div style="display:flex;align-items:center;gap:4px;">
<label style="white-space:nowrap;font-size:12px;color:var(--text-normal);">${t('minWords')}</label>
<input type="number" id="minimap-searchwordcountmin" value="${this.settings.searchWordCountMin ?? 0}" min="0" max="100" style="width:60px;padding:4px 6px;border:1px solid var(--background-modifier-border);border-radius:4px;background:var(--background-primary);color:var(--text-normal);font-size:12px;">
</div>
<div style="display:flex;align-items:center;gap:4px;">
<label style="white-space:nowrap;font-size:12px;color:var(--text-normal);">${t('maxWords')}</label>
<input type="number" id="minimap-searchwordcountmax" value="${this.settings.searchWordCountMax ?? 0}" min="0" max="100" style="width:60px;padding:4px 6px;border:1px solid var(--background-modifier-border);border-radius:4px;background:var(--background-primary);color:var(--text-normal);font-size:12px;">
</div>
</div>
<div class="minimap-settings-row">
<label>${t('multiKeywordRequireAll')}</label>
<input type="checkbox" id="minimap-multikeywordrequireall" ${this.settings.multiKeywordRequireAll !== false ? 'checked' : ''}>
</div>
<div class="minimap-settings-row">
<label>${t('searchOnInput')}</label>
<input type="checkbox" id="minimap-searchoninput" ${this.settings.searchOnInput ? 'checked' : ''}>
</div>
</div>
<div class="minimap-settings-tab-content" data-tab="counter">
<div class="minimap-settings-section-title">${t('pinIcon')}</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px;align-items:center;">
<div style="display:flex;align-items:center;gap:4px;">
<label style="white-space:nowrap;font-size:12px;color:var(--text-normal);">${t('enablePinIcon')}</label>
<input type="checkbox" id="minimap-piniconenabled" ${this.settings.pinIconEnabled ? 'checked' : ''}>
</div>
<div style="display:flex;align-items:center;gap:4px;">
<label style="white-space:nowrap;font-size:12px;color:var(--text-normal);">${t('iconMode')}</label>
<select id="minimap-piniconmode" style="padding:4px 6px;border:1px solid var(--background-modifier-border);border-radius:4px;background:var(--background-primary);color:var(--text-normal);font-size:12px;">
<option value="follow" ${this.settings.pinIconMode === 'follow' ? 'selected' : ''}>${t('followMouse')}</option>
<option value="fixed" ${this.settings.pinIconMode === 'fixed' ? 'selected' : ''}>${t('fixedPosition')}</option>
</select>
</div>
<div style="display:flex;align-items:center;gap:4px;">
<label style="white-space:nowrap;font-size:12px;color:var(--text-normal);">${t('iconSize')}</label>
<input type="number" id="minimap-piniconsize" value="${this.settings.pinIconSize}" min="12" max="40" style="width:55px;padding:4px 6px;border:1px solid var(--background-modifier-border);border-radius:4px;background:var(--background-primary);color:var(--text-normal);font-size:12px;">
</div>
<div style="display:flex;align-items:center;gap:4px;">
<label style="white-space:nowrap;font-size:12px;color:var(--text-normal);">${t('iconOpacity')}</label>
<input type="range" id="minimap-piniconopacity" value="${this.settings.pinIconOpacity}" min="0.1" max="1" step="0.1" style="width:70px;">
<span id="minimap-piniconopacity-value" style="font-size:11px;min-width:24px;">${this.settings.pinIconOpacity}</span>
</div>
</div>
<div id="minimap-follow-offset-section" style="display:${this.settings.pinIconMode === 'follow' ? 'block' : 'none'};">
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px;align-items:center;">
<div style="display:flex;align-items:center;gap:4px;">
<label style="white-space:nowrap;font-size:12px;color:var(--text-normal);">${t('followOffsetX')}</label>
<input type="number" id="minimap-piniconfollowoffsetx" value="${this.settings.pinIconFollowOffsetX ?? 15}" min="-100" max="100" step="1" style="width:60px;padding:4px 6px;border:1px solid var(--background-modifier-border);border-radius:4px;background:var(--background-primary);color:var(--text-normal);font-size:12px;">
</div>
<div style="display:flex;align-items:center;gap:4px;">
<label style="white-space:nowrap;font-size:12px;color:var(--text-normal);">${t('followOffsetY')}</label>
<input type="number" id="minimap-piniconfollowoffsety" value="${this.settings.pinIconFollowOffsetY ?? -10}" min="-100" max="100" step="1" style="width:60px;padding:4px 6px;border:1px solid var(--background-modifier-border);border-radius:4px;background:var(--background-primary);color:var(--text-normal);font-size:12px;">
</div>
</div>
<div id="minimap-follow-offset-preview" style="position:relative;width:120px;height:80px;margin:6px auto;border:1px solid var(--background-modifier-border);border-radius:4px;background:var(--background-secondary);overflow:hidden;">
<div id="minimap-follow-offset-cursor" style="position:absolute;width:6px;height:6px;background:var(--text-muted);border-radius:50%;left:50%;top:50%;transform:translate(-50%,-50%);"></div>
<div id="minimap-follow-offset-icon" style="position:absolute;width:14px;height:14px;background:var(--interactive-accent);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:8px;color:white;font-weight:bold;">m</div>
</div>
</div>
<div class="minimap-settings-section-title" style="margin-top:12px;">${t('colorSchemes')}</div>
<div id="minimap-color-schemes"></div>
<div class="minimap-settings-section-title" style="margin-top:12px;">${t('counterStyle')}</div>
<div id="minimap-counter-preset-chips" style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px;"></div>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px;align-items:center;">
<div style="display:flex;align-items:center;gap:4px;">
<label style="white-space:nowrap;font-size:12px;color:var(--text-normal);">${t('matchOpacity')}</label>
<input type="range" id="minimap-matchopacity" value="${this.settings.matchOpacity ?? 0.6}" min="0.1" max="1" step="0.05" style="width:70px;">
<span id="minimap-matchopacity-value" style="font-size:11px;min-width:28px;">${this.settings.matchOpacity ?? 0.6}</span>
</div>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px;align-items:center;">
<div style="display:flex;align-items:center;gap:4px;">
<label style="white-space:nowrap;font-size:12px;color:var(--text-normal);">${t('counterFontSize')}</label>
<input type="number" id="minimap-countersize" value="${this.settings.counterSize}" min="6" max="16" style="width:55px;padding:4px 6px;border:1px solid var(--background-modifier-border);border-radius:4px;background:var(--background-primary);color:var(--text-normal);font-size:12px;">
</div>
<div style="display:flex;align-items:center;gap:4px;">
<label style="white-space:nowrap;font-size:12px;color:var(--text-normal);">${t('counterTopOffset')}</label>
<input type="number" id="minimap-countertopoffset" value="${this.settings.counterTopOffset}" min="-30" max="10" style="width:55px;padding:4px 6px;border:1px solid var(--background-modifier-border);border-radius:4px;background:var(--background-primary);color:var(--text-normal);font-size:12px;">
</div>
</div>
</div>
<div class="minimap-settings-tab-content" data-tab="pinned">
<div class="minimap-settings-row">
<label>${t('clearCountOnClose')}</label>
<input type="checkbox" id="minimap-clearcountonclose" ${this.settings.clearCountOnClose ? 'checked' : ''}>
</div>
<div class="minimap-settings-section-title">${t('pinnedMatches')}</div>
<button id="minimap-clearallcounts" style="width:100%;margin-bottom:6px;padding:4px 8px;cursor:pointer;">${t('clearAllCounts')}</button>
<div id="minimap-pinned-list" style="max-height:400px;overflow-y:auto;"></div>
</div>
<div class="minimap-settings-footer">
<button id="minimap-reset">${t('resetDefault')}</button>
</div>
</div>
`;
document.body.appendChild(this.settingsPanel);
// Restore saved panel width
const savedPanelWidth = localStorage.getItem('minimap-settings-panel-width');
if (savedPanelWidth) {
this.settingsPanel.style.width = savedPanelWidth;
}
// Save panel width on resize
const resizeObserver = new ResizeObserver(() => {
if (!this.settingsPanel) return;
const width = this.settingsPanel.getBoundingClientRect().width;
localStorage.setItem('minimap-settings-panel-width', `${width}px`);
});
resizeObserver.observe(this.settingsPanel);
this._settingsPanelResizeObserver = resizeObserver;
// Center the panel in the window
const panelRect = this.settingsPanel.getBoundingClientRect();
const centerX = (window.innerWidth - panelRect.width) / 2;
const centerY = (window.innerHeight - panelRect.height) / 2;
this.settingsPanel.style.left = `${Math.max(0, centerX)}px`;
this.settingsPanel.style.top = `${Math.max(0, centerY)}px`;
// Make panel draggable by header
const header = this.settingsPanel.querySelector('.minimap-settings-header');
let isDraggingPanel = false;
let dragOffsetX = 0;
let dragOffsetY = 0;
header.addEventListener('mousedown', (e) => {
if (e.target.classList.contains('minimap-settings-close')) return;
if (e.target.id === 'minimap-lang-toggle') return;
if (!this.settingsPanel) return;
isDraggingPanel = true;
dragOffsetX = e.clientX - this.settingsPanel.getBoundingClientRect().left;
dragOffsetY = e.clientY - this.settingsPanel.getBoundingClientRect().top;
header.style.cursor = 'grabbing';
e.preventDefault();
});
const onPanelMouseMove = (e) => {
if (!isDraggingPanel) return;
if (!this.settingsPanel) return;
const newLeft = e.clientX - dragOffsetX;
const newTop = e.clientY - dragOffsetY;
this.settingsPanel.style.left = `${newLeft}px`;
this.settingsPanel.style.top = `${newTop}px`;
};
const onPanelMouseUp = () => {
if (isDraggingPanel) {
isDraggingPanel = false;
header.style.cursor = 'grab';
}
};
document.addEventListener('mousemove', onPanelMouseMove);
document.addEventListener('mouseup', onPanelMouseUp);
this._settingsPanelDragCleanup = () => {
document.removeEventListener('mousemove', onPanelMouseMove);
document.removeEventListener('mouseup', onPanelMouseUp);
};
const closeBtn = this.settingsPanel.querySelector('.minimap-settings-close');
closeBtn.addEventListener('click', () => this.closeSettingsPanel());
const langToggle = this.settingsPanel.querySelector('#minimap-lang-toggle');
if (langToggle) {
langToggle.addEventListener('click', async (e) => {
e.stopPropagation();
e.preventDefault();
this.settings.language = this.settings.language === 'zh' ? 'en' : 'zh';
window._swiftMatchLang = this.settings.language;
this.saveSettings();
// Close and reopen settings panel to apply language change
this.closeSettingsPanel();
setTimeout(() => {
const mockEvent = { preventDefault: () => {}, stopPropagation: () => {} };
this.showSettingsPanel(mockEvent);
}, 100);
});
}
this.settingsPanel.addEventListener('click', (e) => {
e.stopPropagation();
});
this.settingsPanel.querySelectorAll('.minimap-settings-tab').forEach(tab => {
tab.addEventListener('click', () => {
if (!this.settingsPanel) return;
this.settingsPanel.querySelectorAll('.minimap-settings-tab').forEach(t => t.classList.remove('active'));
this.settingsPanel.querySelectorAll('.minimap-settings-tab-content').forEach(c => c.classList.remove('active'));
tab.classList.add('active');
const tabName = tab.dataset.tab;
this.settingsPanel.querySelector(`.minimap-settings-tab-content[data-tab="${tabName}"]`).classList.add('active');
});
});
this.settingsPanel.querySelectorAll('input[type="number"]').forEach(input => {
input.addEventListener('wheel', (e) => {
e.preventDefault();
const step = parseFloat(input.step) || 1;
const delta = e.deltaY > 0 ? -step : step;
const newVal = parseFloat(input.value) + delta;
const min = parseFloat(input.min);
const max = parseFloat(input.max);
if (!isNaN(min) && newVal < min) {
input.value = min;
} else if (!isNaN(max) && newVal > max) {
input.value = max;
} else {
input.value = newVal;
}
input.dispatchEvent(new Event('input'));
}, { passive: false });
});
const inputs = {
blacklist: this.settingsPanel.querySelector('#minimap-blacklist'),
top: this.settingsPanel.querySelector('#minimap-top'),
width: this.settingsPanel.querySelector('#minimap-width'),
maxwidth: this.settingsPanel.querySelector('#minimap-maxwidth'),
heightoffset: this.settingsPanel.querySelector('#minimap-heightoffset'),
rightoffset: this.settingsPanel.querySelector('#minimap-rightoffset'),
slidercolor: this.settingsPanel.querySelector('#minimap-slidercolor'),
opacity: this.settingsPanel.querySelector('#minimap-opacity'),
contentopacity: this.settingsPanel.querySelector('#minimap-contentopacity'),
collapseonhover: this.settingsPanel.querySelector('#minimap-collapseonhover'),
collapsewidth: this.settingsPanel.querySelector('#minimap-collapsewidth'),
matchopacity: this.settingsPanel.querySelector('#minimap-matchopacity'),
countersize: this.settingsPanel.querySelector('#minimap-countersize'),
countertopoffset: this.settingsPanel.querySelector('#minimap-countertopoffset'),
clearcountonclose: this.settingsPanel.querySelector('#minimap-clearcountonclose'),
piniconenabled: this.settingsPanel.querySelector('#minimap-piniconenabled'),
piniconmode: this.settingsPanel.querySelector('#minimap-piniconmode'),
piniconsize: this.settingsPanel.querySelector('#minimap-piniconsize'),
piniconopacity: this.settingsPanel.querySelector('#minimap-piniconopacity'),
piniconfollowoffsetx: this.settingsPanel.querySelector('#minimap-piniconfollowoffsetx'),
piniconfollowoffsety: this.settingsPanel.querySelector('#minimap-piniconfollowoffsety'),
searchwordcountmin: this.settingsPanel.querySelector('#minimap-searchwordcountmin'),
searchwordcountmax: this.settingsPanel.querySelector('#minimap-searchwordcountmax'),
multikeywordrequireall: this.settingsPanel.querySelector('#minimap-multikeywordrequireall'),
searchoninput: this.settingsPanel.querySelector('#minimap-searchoninput'),
outlineonhover: this.settingsPanel.querySelector('#minimap-outlineonhover'),
outlinepinnedmargin: this.settingsPanel.querySelector('#minimap-outlinepinnedmargin')
};
const matchOpacityValue = this.settingsPanel.querySelector('#minimap-matchopacity-value');
const opacityValue = this.settingsPanel.querySelector('#minimap-opacity-value');
const contentOpacityValue = this.settingsPanel.querySelector('#minimap-contentopacity-value');
const pinIconOpacityValue = this.settingsPanel.querySelector('#minimap-piniconopacity-value');
const updateSettings = () => {
this.settings.minimapBlacklist = inputs.blacklist.value;
this.settings.top = parseFloat(inputs.top.value) || DEFAULT_SETTINGS.top;
this.settings.width = parseFloat(inputs.width.value) || DEFAULT_SETTINGS.width;
this.settings.maxWidth = parseFloat(inputs.maxwidth.value) || DEFAULT_SETTINGS.maxWidth;
this.settings.heightOffset = parseFloat(inputs.heightoffset.value) || DEFAULT_SETTINGS.heightOffset;
this.settings.rightOffset = parseFloat(inputs.rightoffset.value) || DEFAULT_SETTINGS.rightOffset;
this.settings.sliderColor = inputs.slidercolor.value;
this.settings.opacity = parseFloat(inputs.opacity.value) || DEFAULT_SETTINGS.opacity;
this.settings.contentOpacity = parseFloat(inputs.contentopacity.value) || DEFAULT_SETTINGS.contentOpacity;
this.settings.collapseOnHover = inputs.collapseonhover.checked;
this.settings.collapseWidth = parseFloat(inputs.collapsewidth.value) || DEFAULT_SETTINGS.collapseWidth;
this.settings.matchOpacity = parseFloat(inputs.matchopacity.value) || DEFAULT_SETTINGS.matchOpacity;
this.settings.counterSize = parseFloat(inputs.countersize.value) || DEFAULT_SETTINGS.counterSize;
this.settings.counterTopOffset = isNaN(parseFloat(inputs.countertopoffset.value)) ? DEFAULT_SETTINGS.counterTopOffset : parseFloat(inputs.countertopoffset.value);
this.settings.clearCountOnClose = inputs.clearcountonclose.checked;
this.settings.searchWordCountMin = parseInt(inputs.searchwordcountmin.value) || 0;
this.settings.searchWordCountMax = parseInt(inputs.searchwordcountmax.value) || 0;
this.settings.pinIconEnabled = inputs.piniconenabled.checked;
this.settings.pinIconMode = inputs.piniconmode.value;
this.settings.pinIconSize = parseFloat(inputs.piniconsize.value) || DEFAULT_SETTINGS.pinIconSize;
this.settings.pinIconOpacity = parseFloat(inputs.piniconopacity.value) || DEFAULT_SETTINGS.pinIconOpacity;
this.settings.pinIconFollowOffsetX = parseInt(inputs.piniconfollowoffsetx.value) ?? DEFAULT_SETTINGS.pinIconFollowOffsetX;
this.settings.pinIconFollowOffsetY = parseInt(inputs.piniconfollowoffsety.value) ?? DEFAULT_SETTINGS.pinIconFollowOffsetY;
this.settings.multiKeywordRequireAll = inputs.multikeywordrequireall.checked;
this.settings.searchOnInput = inputs.searchoninput.checked;
this.settings.outlineOnHover = inputs.outlineonhover.checked;
this.settings.outlinePinnedMargin = inputs.outlinepinnedmargin.checked;
opacityValue.textContent = this.settings.opacity.toFixed(1);
contentOpacityValue.textContent = this.settings.contentOpacity.toFixed(1);
matchOpacityValue.textContent = this.settings.matchOpacity.toFixed(2);
pinIconOpacityValue.textContent = this.settings.pinIconOpacity.toFixed(1);
this.saveSettings();
this.applySettings();
this.setupMinimap();
this.applyCounterStyles();
this.updateFloatingToggleStyle();
renderColorSchemes();
if (this.currentSelection) this.highlightMatches();
};
Object.values(inputs).forEach(input => {
input.addEventListener('input', updateSettings);
});
const followOffsetSection = this.settingsPanel.querySelector('#minimap-follow-offset-section');
const followOffsetIcon = this.settingsPanel.querySelector('#minimap-follow-offset-icon');
const updateFollowOffsetVisibility = () => {
followOffsetSection.style.display = inputs.piniconmode.value === 'follow' ? 'block' : 'none';
};
const updateFollowOffsetPreview = () => {
const ox = parseInt(inputs.piniconfollowoffsetx.value) || 0;
const oy = parseInt(inputs.piniconfollowoffsety.value) || 0;
const previewW = 120, previewH = 80;
const iconSize = 14;
const cx = previewW / 2;
const cy = previewH / 2;
const scale = 0.5;
const ix = cx + ox * scale - iconSize / 2;
const iy = cy + oy * scale - iconSize / 2;
followOffsetIcon.style.left = `${Math.max(0, Math.min(previewW - iconSize, ix))}px`;
followOffsetIcon.style.top = `${Math.max(0, Math.min(previewH - iconSize, iy))}px`;
};
inputs.piniconmode.addEventListener('change', updateFollowOffsetVisibility);
inputs.piniconfollowoffsetx.addEventListener('input', updateFollowOffsetPreview);
inputs.piniconfollowoffsety.addEventListener('input', updateFollowOffsetPreview);
updateFollowOffsetPreview();
const resetBtn = this.settingsPanel.querySelector('#minimap-reset');
resetBtn.addEventListener('click', () => {
this.settings = Object.assign({}, DEFAULT_SETTINGS);
inputs.top.value = this.settings.top;
inputs.width.value = this.settings.width;
inputs.maxwidth.value = this.settings.maxWidth;
inputs.heightoffset.value = this.settings.heightOffset;
inputs.rightoffset.value = this.settings.rightOffset;
inputs.slidercolor.value = this.settings.sliderColor;
inputs.opacity.value = this.settings.opacity;
inputs.contentopacity.value = this.settings.contentOpacity;
inputs.collapseonhover.checked = this.settings.collapseOnHover;
inputs.collapsewidth.value = this.settings.collapseWidth;
inputs.matchopacity.value = this.settings.matchOpacity ?? DEFAULT_SETTINGS.matchOpacity;
inputs.countersize.value = this.settings.counterSize;
inputs.countertopoffset.value = this.settings.counterTopOffset;
inputs.clearcountonclose.checked = this.settings.clearCountOnClose;
inputs.piniconenabled.checked = this.settings.pinIconEnabled;
inputs.piniconmode.value = this.settings.pinIconMode;
inputs.piniconsize.value = this.settings.pinIconSize;
inputs.piniconopacity.value = this.settings.pinIconOpacity;
inputs.piniconfollowoffsetx.value = this.settings.pinIconFollowOffsetX ?? DEFAULT_SETTINGS.pinIconFollowOffsetX;
inputs.piniconfollowoffsety.value = this.settings.pinIconFollowOffsetY ?? DEFAULT_SETTINGS.pinIconFollowOffsetY;
inputs.multikeywordrequireall.checked = this.settings.multiKeywordRequireAll !== false;
inputs.searchoninput.checked = this.settings.searchOnInput;
inputs.outlineonhover.checked = this.settings.outlineOnHover;
inputs.outlinepinnedmargin.checked = this.settings.outlinePinnedMargin !== false;
inputs.searchwordcountmin.value = this.settings.searchWordCountMin ?? 0;
inputs.searchwordcountmax.value = this.settings.searchWordCountMax ?? 0;
opacityValue.textContent = this.settings.opacity.toFixed(1);
contentOpacityValue.textContent = this.settings.contentOpacity.toFixed(1);
matchOpacityValue.textContent = this.settings.matchOpacity.toFixed(2);
pinIconOpacityValue.textContent = this.settings.pinIconOpacity.toFixed(1);
// 重置悬浮按钮样式为 candy-mark 默认样式
this.floatingStyles = [{
id: Date.now().toString(),
name: this.settings.floatingToggleText || 'Swift',
styleClass: this.settings.floatingToggleStyleClass,
customStyle: this.settings.floatingToggleCustomStyle
}];
this.floatingDefaultStyleId = this.floatingStyles[0].id;
this.saveFloatingStyleData();
this.applyFloatingCustomStyle();
// 重置后悬浮按钮默认显示
this.settings.floatingToggleVisible = true;
if (this.floatingToggleWrapper) {
this.floatingToggleWrapper.remove();
this.floatingToggleWrapper = null;
this.floatingToggle = null;
this.floatingToggleText = null;
this.floatingSearchBox = null;
}
this.createFloatingToggle();
this.saveSettings();
this.applySettings();
if (this._applySettingsBtnStyle) this._applySettingsBtnStyle();
renderColorSchemes();
renderPinnedList();
});
const floatingToggleBtn = this.settingsPanel.querySelector('#minimap-showfloatingtoggle');
// Apply current floating toggle style to the settings preview span
const applyBtnStyle = () => {
const el = floatingToggleBtn;
const activeStyle = this.getActiveFloatingStyle();
const styleClass = activeStyle ? (activeStyle.styleClass || '') : (this.settings.floatingToggleStyleClass || '');
const customStyle = activeStyle ? (activeStyle.customStyle || '') : (this.settings.floatingToggleCustomStyle || '');
const hasCustomStyle = !!(styleClass || customStyle);
const text = this.settings.floatingToggleText || 'Swift';
const fontSize = this.settings.floatingToggleFontSize || 11;
const opacity = this.settings.floatingToggleOpacity ?? 0.6;
el.textContent = text;
if (hasCustomStyle) {
el.style.cssText = `cursor:pointer;display:inline-flex;align-items:center;justify-content:center;font-size:${fontSize}px;opacity:${opacity};white-space:nowrap;line-height:1;padding:0;`;
el.className = '';
if (styleClass) el.classList.add(`swift-match-floating-style-${styleClass}`);
// Inject scoped CSS for preview
let previewStyleEl = document.getElementById('swift-match-settings-preview-style');
if (!previewStyleEl) {
previewStyleEl = document.createElement('style');
previewStyleEl.id = 'swift-match-settings-preview-style';
document.head.appendChild(previewStyleEl);
}
if (customStyle) {
let scoped = customStyle.replace(/\.([a-zA-Z_-][\w-]*)/g, '.swift-match-floating-style-$1');
previewStyleEl.textContent = scoped;
const firstClass = customStyle.match(/\.([a-zA-Z_-][\w-]*)/);
if (firstClass && !styleClass) el.classList.add(`swift-match-floating-style-${firstClass[1]}`);
}
} else {
el.className = '';
el.style.cssText = `cursor:pointer;display:inline-flex;align-items:center;justify-content:center;font-size:${fontSize}px;opacity:${opacity};white-space:nowrap;line-height:1;padding:2px 10px;border-radius:9999px;background-color:rgba(240,100,120,0.08);box-shadow:inset 0 0 0 1px rgba(240,120,100,0.4),0 2px 10px rgba(0,0,0,0.05);font-weight:800;background:linear-gradient(135deg,#f2709c,#ff9472,#f5af19,#f2709c);background-size:250% 100%;-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;filter:drop-shadow(0 0 6px rgba(240,100,120,0.7)) drop-shadow(0 0 12px rgba(245,150,50,0.5));animation:hp-twilight-move 3.5s linear infinite;`;
}
};
applyBtnStyle();
this._applySettingsBtnStyle = applyBtnStyle;
floatingToggleBtn.addEventListener('click', () => {
if (this.floatingToggleWrapper) {
this.floatingToggleWrapper.remove();
this.floatingToggleWrapper = null;
this.floatingToggle = null;
this.floatingToggleText = null;
this.floatingSearchBox = null;
this.settings.floatingToggleVisible = false;
this.saveSettings();
} else {
this.createFloatingToggle();
}
});
// Render color schemes
const colorSchemesContainer = this.settingsPanel.querySelector('#minimap-color-schemes');
let _colorSchemePopup = null;
const closeColorSchemePopup = () => {
if (_colorSchemePopup) { _colorSchemePopup.remove(); _colorSchemePopup = null; }
};
const renderColorSchemes = () => {
colorSchemesContainer.innerHTML = '';
closeColorSchemePopup();
const schemes = this.settings.pinColorSchemes || [];
const presetClass = this.getCounterPresetClass().trim();
const chipsRow = document.createElement('div');
chipsRow.style.cssText = 'display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:4px 0;';
schemes.forEach((scheme, index) => {
const chip = document.createElement('div');
chip.style.cssText = `
display:inline-flex;align-items:flex-end;cursor:pointer;
position:relative;user-select:none;
transition:box-shadow 0.15s ease, transform 0.1s ease;
margin-top:14px;
background:rgba(255,255,255,0.06);
border-radius:8px;
padding:2px 6px 2px 4px;
backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);
`;
// Text part with rounded underline (same as actual highlight)
const textSpan = document.createElement('span');
const matchOpacity = this.settings.matchOpacity ?? 0.6;
const c = this.hexToRgba(scheme.borderColor, matchOpacity);
textSpan.style.cssText = `
margin:0 -0.2em;padding:0 0.2em;
-webkit-box-decoration-break:clone;box-decoration-break:clone;
background:radial-gradient(farthest-side,${c} 98%,#0000) bottom left,linear-gradient(${c} 0 0) bottom,radial-gradient(farthest-side,${c} 98%,#0000) bottom right;
background-size:8px 8px,calc(100% - 8px) 8px;
background-repeat:no-repeat;
color:var(--text-normal);font-size:12px;line-height:1.4;
`;
textSpan.textContent = 'Preview';
chip.appendChild(textSpan);
// Counter badge (positioned like actual counter - above text)
const counter = document.createElement('span');
const counterFontSize = this.settings.counterSize;
const counterColor = scheme.counterColor;
let counterStyle = `
position:absolute;
top:var(--minimap-counter-top-offset, -10px);
left:0;
display:inline-flex;align-items:center;justify-content:center;
font-size:${counterFontSize}px;line-height:1;
padding:0px 2px;
white-space:nowrap;
pointer-events:none;
`;
if (presetClass === 'minimap-counter-glass') {
const isDark = document.body.classList.contains('theme-dark');
const glassCounterColor = isDark ? '#e0dcdc' : '#aba6a6';
counterStyle += `background:rgba(255,255,255,0.15);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);border:1px solid rgba(255,255,255,0.25);box-shadow:0 2px 8px rgba(0,0,0,0.12),inset 0 1px 0 rgba(255,255,255,0.2);text-shadow:0 1px 2px rgba(0,0,0,0.3);border-radius:4px;color:${glassCounterColor};`;
} else if (presetClass === 'minimap-counter-outlined') {
const outlinedBorder = scheme.borderLinked !== false ? scheme.borderColor : scheme.counterBgColor;
const outlinedColor = scheme.counterLinked !== false ? outlinedBorder : scheme.counterColor;
counterStyle += `background:transparent;border:1.5px solid ${outlinedBorder};border-radius:4px;color:${outlinedColor};text-shadow:none;`;
}
counter.style.cssText = counterStyle;
counter.textContent = `${index + 1}/${schemes.length}`;
chip.appendChild(counter);
chip.addEventListener('mouseenter', () => { chip.style.boxShadow = '0 0 0 2px var(--interactive-accent)'; chip.style.transform = 'scale(1.04)'; });
chip.addEventListener('mouseleave', () => { chip.style.boxShadow = 'none'; chip.style.transform = 'none'; });
chip.addEventListener('click', (e) => {
e.stopPropagation();
closeColorSchemePopup();
const popup = document.createElement('div');
popup.style.cssText = `
position:absolute;z-index:10000;
background:var(--background-secondary);
border:1px solid var(--background-modifier-border);
border-radius:8px;padding:10px 12px;
box-shadow:0 4px 16px rgba(0,0,0,0.2);
display:flex;flex-direction:column;gap:8px;
`;
const containerRect = colorSchemesContainer.getBoundingClientRect();
const panelRect = this.settingsPanel.getBoundingClientRect();
popup.style.top = `${containerRect.bottom - panelRect.top + 4}px`;
popup.style.left = `${Math.max(0, containerRect.left - panelRect.left)}px`;
const updateChipStyle = () => {
const s = this.settings.pinColorSchemes[index];
const newC = this.hexToRgba(s.borderColor, this.settings.matchOpacity ?? 0.6);
textSpan.style.background = `radial-gradient(farthest-side,${newC} 98%,#0000) bottom left,linear-gradient(${newC} 0 0) bottom,radial-gradient(farthest-side,${newC} 98%,#0000) bottom right`;
textSpan.style.backgroundSize = '8px 8px,calc(100% - 8px) 8px';
textSpan.style.backgroundRepeat = 'no-repeat';
// Update counter style based on preset
const currentPreset = this.settings.counterStylePreset || 'glass';
if (currentPreset === 'glass') {
const isDark = document.body.classList.contains('theme-dark');
counter.style.color = isDark ? '#e0dcdc' : '#aba6a6';
counter.style.background = 'rgba(255,255,255,0.15)';
} else {
const newCounterBg = s.borderLinked !== false ? s.borderColor : s.counterBgColor;
counter.style.borderColor = newCounterBg;
counter.style.color = s.counterLinked !== false ? newCounterBg : s.counterColor;
}
};
const refreshHighlights = () => {
this.saveSettings();
updateChipStyle();
this.applyCounterStyles();
if (this.currentSelection) this.highlightMatches();
};
const currentPreset = this.settings.counterStylePreset || 'glass';
// Horizontal row
const fieldsRow = document.createElement('div');
fieldsRow.style.cssText = 'display:flex;align-items:center;gap:6px;';
// 下划线 (always shown)
const underlineGroup = document.createElement('div');
underlineGroup.style.cssText = 'display:flex;align-items:center;gap:2px;';
underlineGroup.innerHTML = `<label style="font-size:10px;min-width:28px;">${t('underline')}</label>`;
const underlineInput = document.createElement('input');
underlineInput.type = 'color';
underlineInput.value = scheme.borderColor;
underlineInput.style.cssText = 'width:32px;height:24px;padding:0;cursor:pointer;';
underlineInput.addEventListener('input', (ev) => {
this.settings.pinColorSchemes[index].borderColor = ev.target.value;
refreshHighlights();
});
underlineGroup.appendChild(underlineInput);
fieldsRow.appendChild(underlineGroup);
if (currentPreset === 'glass') {
// 毛玻璃: 下划线 only (计数颜色固定,深浅自动切换)
} else if (currentPreset === 'outlined') {
// 描边气泡: 下划线 | =/≠ | 气泡边框 | =/≠ | 计数文字
// = / ≠ toggle between 下划线 and 气泡边框
const borderLinked = scheme.borderLinked !== false;
const borderToggle = document.createElement('span');
borderToggle.textContent = borderLinked ? '=' : '≠';
borderToggle.style.cssText = `
font-size:13px;font-weight:bold;cursor:pointer;
color:${borderLinked ? 'var(--interactive-accent)' : 'var(--text-muted)'};
user-select:none;padding:0 2px;
transition:color 0.15s ease;
`;
borderToggle.addEventListener('mouseenter', () => { borderToggle.style.color = 'var(--interactive-accent)'; });
borderToggle.addEventListener('mouseleave', () => { borderToggle.style.color = this.settings.pinColorSchemes[index].borderLinked !== false ? 'var(--interactive-accent)' : 'var(--text-muted)'; });
borderToggle.addEventListener('click', () => {
const s = this.settings.pinColorSchemes[index];
if (s.borderLinked !== false) {
s.borderLinked = false;
borderToggle.textContent = '≠';
borderToggle.style.color = 'var(--text-muted)';
borderInput.style.opacity = '1';
} else {
s.borderLinked = true;
s.counterBgColor = s.borderColor;
borderInput.value = s.borderColor;
borderToggle.textContent = '=';
borderToggle.style.color = 'var(--interactive-accent)';
borderInput.style.opacity = '0.4';
// Also sync counter if counterLinked
if (s.counterLinked !== false) {
s.counterColor = s.counterBgColor;
colorInput.value = s.counterBgColor;
}
refreshHighlights();
}
});
fieldsRow.appendChild(borderToggle);
// 气泡边框
const borderGroup = document.createElement('div');
borderGroup.style.cssText = 'display:flex;align-items:center;gap:2px;';
borderGroup.innerHTML = `<label style="font-size:10px;min-width:28px;">${t('bubbleBorder')}</label>`;
const borderInput = document.createElement('input');
borderInput.type = 'color';
borderInput.value = borderLinked ? scheme.borderColor : scheme.counterBgColor;
borderInput.style.cssText = 'width:32px;height:24px;padding:0;cursor:pointer;';
if (borderLinked) borderInput.style.opacity = '0.4';
borderInput.addEventListener('input', (ev) => {
if (this.settings.pinColorSchemes[index].borderLinked !== false) return;
this.settings.pinColorSchemes[index].counterBgColor = ev.target.value;
if (this.settings.pinColorSchemes[index].counterLinked !== false) {
this.settings.pinColorSchemes[index].counterColor = ev.target.value;
colorInput.value = ev.target.value;
}
refreshHighlights();
});
borderGroup.appendChild(borderInput);
fieldsRow.appendChild(borderGroup);
// = / ≠ toggle between 气泡边框 and 计数文字
const linked = scheme.counterLinked !== false;
const toggleSpan = document.createElement('span');
toggleSpan.textContent = linked ? '=' : '≠';
toggleSpan.style.cssText = `
font-size:13px;font-weight:bold;cursor:pointer;
color:${linked ? 'var(--interactive-accent)' : 'var(--text-muted)'};
user-select:none;padding:0 2px;
transition:color 0.15s ease;
`;
toggleSpan.addEventListener('mouseenter', () => { toggleSpan.style.color = 'var(--interactive-accent)'; });
toggleSpan.addEventListener('mouseleave', () => { toggleSpan.style.color = this.settings.pinColorSchemes[index].counterLinked !== false ? 'var(--interactive-accent)' : 'var(--text-muted)'; });
toggleSpan.addEventListener('click', () => {
const s = this.settings.pinColorSchemes[index];
if (s.counterLinked !== false) {
s.counterLinked = false;
toggleSpan.textContent = '≠';
toggleSpan.style.color = 'var(--text-muted)';
colorInput.style.opacity = '1';
} else {
s.counterLinked = true;
s.counterColor = s.counterBgColor;
colorInput.value = s.counterBgColor;
toggleSpan.textContent = '=';
toggleSpan.style.color = 'var(--interactive-accent)';
colorInput.style.opacity = '0.4';
refreshHighlights();
}
});
fieldsRow.appendChild(toggleSpan);
// 计数文字
const colorGroup = document.createElement('div');
colorGroup.style.cssText = 'display:flex;align-items:center;gap:2px;';
colorGroup.innerHTML = `<label style="font-size:10px;min-width:28px;">${t('counterTextColor')}</label>`;
const colorInput = document.createElement('input');
colorInput.type = 'color';
colorInput.value = linked ? (borderLinked ? scheme.borderColor : scheme.counterBgColor) : scheme.counterColor;
colorInput.style.cssText = 'width:32px;height:24px;padding:0;cursor:pointer;';
if (linked) colorInput.style.opacity = '0.4';
colorInput.addEventListener('input', (ev) => {
if (this.settings.pinColorSchemes[index].counterLinked !== false) return;
this.settings.pinColorSchemes[index].counterColor = ev.target.value;
refreshHighlights();
});
colorGroup.appendChild(colorInput);
fieldsRow.appendChild(colorGroup);
// Update underlineInput to sync border when borderLinked
underlineInput.addEventListener('input', (ev) => {
if (this.settings.pinColorSchemes[index].borderLinked !== false) {
this.settings.pinColorSchemes[index].counterBgColor = ev.target.value;
borderInput.value = ev.target.value;
if (this.settings.pinColorSchemes[index].counterLinked !== false) {
this.settings.pinColorSchemes[index].counterColor = ev.target.value;
colorInput.value = ev.target.value;
}
}
});
}
popup.appendChild(fieldsRow);
// Delete button
const delRow = document.createElement('div');
delRow.style.cssText = 'display:flex;justify-content:flex-end;margin-top:2px;';
const deleteBtn = document.createElement('button');
deleteBtn.style.cssText = 'font-size:11px;padding:2px 10px;cursor:pointer;color:var(--text-muted);border:1px solid var(--background-modifier-border);border-radius:4px;background:transparent;';
deleteBtn.textContent = t('remove');
deleteBtn.addEventListener('click', () => {
this.settings.pinColorSchemes.splice(index, 1);
this.saveSettings();
closeColorSchemePopup();
renderColorSchemes();
if (this.currentSelection) this.highlightMatches();
});
delRow.appendChild(deleteBtn);
popup.appendChild(delRow);
this.settingsPanel.appendChild(popup);
_colorSchemePopup = popup;
});
chipsRow.appendChild(chip);
});
// "+" add chip
const addChip = document.createElement('div');
addChip.style.cssText = `
display:inline-flex;align-items:center;justify-content:center;
width:30px;height:26px;border-radius:14px;cursor:pointer;
border:2px dashed var(--text-muted);
color:var(--text-muted);font-size:16px;font-weight:bold;
transition:all 0.15s ease;user-select:none;
background:rgba(255,255,255,0.06);
backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);
`;
addChip.textContent = '+';
addChip.addEventListener('mouseenter', () => {
addChip.style.borderColor = 'var(--interactive-accent)';
addChip.style.color = 'var(--interactive-accent)';
addChip.style.background = 'rgba(255,255,255,0.1)';
});
addChip.addEventListener('mouseleave', () => {
addChip.style.borderColor = 'var(--text-muted)';
addChip.style.color = 'var(--text-muted)';
addChip.style.background = 'rgba(255,255,255,0.06)';
});
addChip.addEventListener('click', () => {
const colors = ['#f44336', '#E91E63', '#9C27B0', '#673AB7', '#3F51B5', '#2196F3', '#03A9F4', '#00BCD4', '#009688', '#4CAF50', '#8BC34A', '#CDDC39', '#FFC107', '#FF9800', '#FF5722', '#795548'];
const randomColor = colors[Math.floor(Math.random() * colors.length)];
this.settings.pinColorSchemes.push({
borderColor: randomColor,
counterBgColor: randomColor,
counterColor: '#aba6a6',
borderLinked: true,
counterLinked: true
});
this.saveSettings();
renderColorSchemes();
if (this.currentSelection) this.highlightMatches();
});
chipsRow.appendChild(addChip);
colorSchemesContainer.appendChild(chipsRow);
};
// Close popup on click inside settings panel but outside popup
this.settingsPanel.addEventListener('click', (e) => {
if (_colorSchemePopup && !_colorSchemePopup.contains(e.target)) {
closeColorSchemePopup();
}
});
// Render counter style preset chips
const counterPresetChips = this.settingsPanel.querySelector('#minimap-counter-preset-chips');
const presetOptions = [
{ value: 'glass', label: t('counterPresetGlass') },
{ value: 'outlined', label: t('counterPresetOutlined') }
];
presetOptions.forEach(opt => {
const chip = document.createElement('div');
const isActive = this.settings.counterStylePreset === opt.value;
chip.style.cssText = `
display:inline-flex;align-items:center;justify-content:center;
padding:3px 10px;border-radius:14px;cursor:pointer;
font-size:11px;user-select:none;
border:1.5px solid ${isActive ? 'var(--interactive-accent)' : 'var(--background-modifier-border)'};
background:${isActive ? 'var(--interactive-accent)' : 'transparent'};
color:${isActive ? 'var(--text-on-accent)' : 'var(--text-muted)'};
transition:all 0.15s ease;
`;
chip.textContent = opt.label;
chip.addEventListener('click', () => {
this.settings.counterStylePreset = opt.value;
this.saveSettings();
this.applyCounterStyles();
renderColorSchemes();
// Force refresh all decorations
this.clearHighlights();
this.showPinnedDecorations();
if (this.currentSelection) this.highlightMatches();
// Update chip styles
counterPresetChips.querySelectorAll(':scope > div').forEach((c, i) => {
const active = presetOptions[i].value === opt.value;
c.style.borderColor = active ? 'var(--interactive-accent)' : 'var(--background-modifier-border)';
c.style.background = active ? 'var(--interactive-accent)' : 'transparent';
c.style.color = active ? 'var(--text-on-accent)' : 'var(--text-muted)';
});
});
chip.addEventListener('mouseenter', () => {
if (this.settings.counterStylePreset !== opt.value) {
chip.style.borderColor = 'var(--text-muted)';
}
});
chip.addEventListener('mouseleave', () => {
if (this.settings.counterStylePreset !== opt.value) {
chip.style.borderColor = 'var(--background-modifier-border)';
}
});
counterPresetChips.appendChild(chip);
});
// Render pinned list
const pinnedListContainer = this.settingsPanel.querySelector('#minimap-pinned-list');
const renderPinnedList = () => {
pinnedListContainer.innerHTML = '';
const pinnedItems = this.savedMatchLists.filter(m => m.pinned);
if (pinnedItems.length === 0) {
pinnedListContainer.innerHTML = `<div style="color:var(--text-muted);font-size:12px;padding:8px;">${t('noPinned')}</div>`;
return;
}
// Group by file name
const fileGroups = new Map();
pinnedItems.forEach(item => {
const fileName = item.fileName || t('unknownFile');
if (!fileGroups.has(fileName)) {
fileGroups.set(fileName, []);
}
fileGroups.get(fileName).push(item);
});
fileGroups.forEach((items, fileName) => {
const groupHeader = document.createElement('div');
groupHeader.style.cssText = 'font-size:11px;color:var(--text-muted);padding:4px 0 2px;font-weight:600;display:flex;align-items:center;gap:6px;';
const headerSpan = document.createElement('span');
headerSpan.style.cssText = 'flex:1;';
headerSpan.textContent = fileName;
const clearBtn = document.createElement('button');
clearBtn.style.cssText = 'font-size:10px;padding:0 4px;cursor:pointer;color:var(--text-muted);flex-shrink:0;';
clearBtn.title = t('clearDocPinned');
clearBtn.textContent = t('clear');
clearBtn.addEventListener('click', () => {
this.savedMatchLists = this.savedMatchLists.filter(item => !(item.fileName === fileName && item.pinned));
this.saveListData();
this.clearHighlights();
this.showPinnedDecorations();
if (this.currentSelection) {
this.highlightMatches();
}
renderPinnedList();
});
groupHeader.appendChild(headerSpan);
groupHeader.appendChild(clearBtn);
pinnedListContainer.appendChild(groupHeader);
items.forEach(item => {
const colorScheme = this.getPinnedColorScheme(item);
const row = document.createElement('div');
row.className = 'minimap-settings-row';
row.style.alignItems = 'center';
row.style.gap = '6px';
row.style.padding = '4px 0 4px 12px';
const colorDot = document.createElement('span');
colorDot.style.cssText = `width:12px;height:12px;border-radius:2px;background:${colorScheme.borderColor};flex-shrink:0;`;
const label = document.createElement('span');
label.style.cssText = 'flex:1;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
label.title = item.selection;
label.textContent = item.selection;
const removeBtn = document.createElement('button');
removeBtn.style.cssText = 'font-size:11px;padding:0 4px;cursor:pointer;color:var(--text-muted);';
removeBtn.title = t('remove');
removeBtn.textContent = '×';
removeBtn.addEventListener('click', () => {
this.removePinnedSelection(item.selection);
renderPinnedList();
});
row.appendChild(colorDot);
row.appendChild(label);
row.appendChild(removeBtn);
pinnedListContainer.appendChild(row);
});
});
};
// Bind clear all counts button
const clearAllCountsBtn = this.settingsPanel.querySelector('#minimap-clearallcounts');
if (clearAllCountsBtn) {
clearAllCountsBtn.addEventListener('click', () => {
this.savedMatchLists = this.savedMatchLists.filter(item => !item.pinned);
this.saveListData();
this.clearHighlights();
if (this.currentSelection) {
this.highlightMatches();
}
renderPinnedList();
});
}
renderColorSchemes();
renderPinnedList();
// 初始化折叠功能
this.initCollapsibleSections();
}
closeSettingsPanel() {
if (this._settingsPanelResizeObserver) {
this._settingsPanelResizeObserver.disconnect();
this._settingsPanelResizeObserver = null;
}
if (this._settingsPanelDragCleanup) {
this._settingsPanelDragCleanup();
this._settingsPanelDragCleanup = null;
}
if (this.settingsPanel) {
this.settingsPanel.remove();
this.settingsPanel = null;
}
this._applySettingsBtnStyle = null;
// Clean up settings preview style
const previewStyleEl = document.getElementById('swift-match-settings-preview-style');
if (previewStyleEl) previewStyleEl.remove();
}
createFloatingToggle() {
if (this.floatingToggle) {
if (this.floatingToggleWrapper) {
this.floatingToggleWrapper.remove();
this.floatingToggleWrapper = null;
}
this.floatingToggle = null;
this.floatingToggleText = null;
this.floatingSearchBox = null;
this.settings.floatingToggleVisible = false;
this.saveSettings();
return;
}
const size = this.settings.floatingToggleSize || 20;
const opacity = this.settings.floatingToggleOpacity || 0.6;
const fontSize = this.settings.floatingToggleFontSize || Math.max(10, size * 0.55);
const paddingH = this.settings.floatingTogglePaddingH ?? 10;
const paddingV = this.settings.floatingTogglePaddingV ?? 2;
// Create wrapper container for toggle + search box
this.floatingToggleWrapper = document.createElement('div');
this.floatingToggleWrapper.className = 'minimap-floating-toggle-wrapper';
const isRightSide = !!this.settings.floatingToggleRight;
this.floatingToggleWrapper.style.cssText = `
position: fixed;
z-index: 99999;
display: flex;
align-items: center;
gap: 0;
user-select: none;
${isRightSide
? `right: ${this.settings.floatingToggleRight}px; left: auto;`
: `left: ${this.settings.floatingToggleX || 50}px; right: auto;`}
top: ${this.settings.floatingToggleY || 100}px;
`;
// Search box - absolutely positioned so it doesn't affect toggle position
this.floatingSearchBox = document.createElement('input');
this.floatingSearchBox.className = 'minimap-floating-search-box';
this.floatingSearchBox.type = 'text';
this.floatingSearchBox.placeholder = t('searchPlaceholder');
this.floatingSearchBox.style.cssText = `
width: 100%;
height: 28px;
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
background: var(--background-secondary);
color: var(--text-normal);
font-size: 12px;
padding: 0 8px;
outline: none;
box-sizing: border-box;
`;
this.floatingSearchBox.addEventListener('mousedown', (e) => {
e.stopPropagation();
});
this.floatingSearchBox.addEventListener('input', () => {
if (!this.settings.searchOnInput) return;
if (this._searchDebounceTimer) clearTimeout(this._searchDebounceTimer);
this._searchDebounceTimer = setTimeout(() => {
const query = this.floatingSearchBox.value.trim();
if (query) {
this.currentSelection = query;
this._exhaustiveSearchDone = false;
this._listUserDismissed = false;
this._keepListVisible = true;
this.showMatchList(query, 0);
this._keepListVisible = false;
}
}, 300);
});
this.floatingSearchBox.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (this._searchDebounceTimer) clearTimeout(this._searchDebounceTimer);
const query = this.floatingSearchBox.value.trim();
if (query) {
this.currentSelection = query;
this._exhaustiveSearchDone = false;
this._listUserDismissed = false;
this._keepListVisible = true;
this.showMatchList(query, 0);
this._keepListVisible = false;
}
} else if (e.key === 'Escape') {
this.floatingSearchBox.value = '';
this.floatingSearchBox.blur();
}
});
// Toggle button - wrapper for background
this.floatingToggle = document.createElement('div');
this.floatingToggle.className = 'minimap-floating-toggle';
this.floatingToggle.style.cssText = `
position: relative;
padding: ${paddingV}px ${paddingH}px;
border-radius: 9999px;
background-color: rgba(240, 100, 120, 0.08);
box-shadow: inset 0 0 0 1px rgba(240, 120, 100, 0.4), 0 2px 10px rgba(0, 0, 0, 0.05);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
flex-shrink: 0;
opacity: ${opacity};
transition: transform 0.15s ease, opacity 0.2s ease;
`;
// Inner text element with gradient
this.floatingToggleText = document.createElement('span');
this.floatingToggleText.className = 'swift';
const toggleText = this.settings.floatingToggleText || 'Swift';
this.floatingToggleText.style.cssText = `
font-weight: 800;
background: linear-gradient(135deg, #f2709c, #ff9472, #f5af19, #f2709c);
background-size: 250% 100%;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
filter: drop-shadow(0 0 6px rgba(240, 100, 120, 0.7)) drop-shadow(0 0 12px rgba(245, 150, 50, 0.5));
animation: hp-twilight-move 3.5s linear infinite;
font-size: ${fontSize}px;
white-space: nowrap;
line-height: 1;
padding: 0;
pointer-events: none;
${!this.settings.enableSelectionMatch ? 'text-decoration: line-through; text-decoration-color: rgba(240, 100, 120, 0.7);' : ''}
`;
this.floatingToggleText.textContent = toggleText;
this.floatingToggle.appendChild(this.floatingToggleText);
// Inject keyframes if not already present
if (!document.getElementById('swift-match-twilight-keyframes')) {
const styleEl = document.createElement('style');
styleEl.id = 'swift-match-twilight-keyframes';
styleEl.textContent = `@keyframes hp-twilight-move { 0% { background-position: 0% 50%; } 100% { background-position: 250% 50%; } }`;
document.head.appendChild(styleEl);
}
this.settings.floatingToggleVisible = true;
this.saveSettings();
this.updateFloatingToggleStyle();
this.floatingToggleWrapper.addEventListener('mouseenter', () => {
this.floatingToggle.style.opacity = '1';
this.floatingToggleWrapper.style.zIndex = '100000';
this._floatingToggleHoverTimer = setTimeout(() => {
this._listUserDismissed = false;
this._listTriggerElement = this.floatingToggleWrapper || null;
const hasCachedMatches = (this.settings.enableSelectionMatch && this._cachedMatchList && this._pendingSearchText) ||
(this._listPinnedSearchText && this._pinnedWordFileMap);
if (hasCachedMatches) {
if (this._listPinnedSearchText && this._pinnedWordFileMap) {
this._pendingShowList = { searchText: this._listPinnedSearchText, matchCount: this._pinnedWordMatchCount || 0 };
this._isListVisible = true;
this.renderMatchList(this._pinnedWordFileMap, this._pinnedWordMatchCount || 0, false);
this._listShownFromHover = true;
this.positionListNearFloatingToggle();
} else {
this.showMatchListFromFloatingToggle();
}
} else if (this._recentSearches.length > 0) {
const recentTerm = this._recentSearches[0];
const cached = this._recentSearchCaches[recentTerm];
if (cached && cached.fileMap && cached.fileMap.size > 0) {
this._pendingShowList = { searchText: recentTerm, matchCount: cached.matchCount };
this._cachedMatchList = cached.fileMap;
this._cachedMatchListKey = recentTerm;
this._pendingMatchCount = cached.matchCount;
this._pendingSearchText = recentTerm;
this._isListVisible = true;
this.renderMatchList(cached.fileMap, cached.matchCount, false);
this._listShownFromHover = true;
this.positionListNearFloatingToggle();
} else {
this.showRecentSearchList();
this._listShownFromHover = true;
this.positionListNearFloatingToggle();
}
} else {
this.showRecentSearchList();
this._listShownFromHover = true;
this.positionListNearFloatingToggle();
}
}, 200);
});
this.floatingToggleWrapper.addEventListener('mouseleave', () => {
const op = this.settings.floatingToggleOpacity || 0.6;
this.floatingToggle.style.opacity = op.toString();
this.floatingToggleWrapper.style.zIndex = '99999';
if (this._floatingToggleHoverTimer) {
clearTimeout(this._floatingToggleHoverTimer);
this._floatingToggleHoverTimer = null;
}
if (this._isListVisible && this._listShownFromHover) {
setTimeout(() => {
const keywordBtnHovered = this._floatingKeywordButtons.some(b => b.wrapper.matches(':hover'));
if (this.matchList && !this.matchList.matches(':hover') && !this.floatingToggleWrapper.matches(':hover') && !keywordBtnHovered) {
this.hideMatchList();
this._listShownFromHover = false;
}
}, 100);
}
if (this._isListVisible && !this.isPreviewOpen) {
setTimeout(() => {
const listHovered = this.matchList && this.matchList.matches(':hover');
const toggleWrapperHovered = this.floatingToggleWrapper && this.floatingToggleWrapper.matches(':hover');
const keywordBtnHovered = this._floatingKeywordButtons.some(b => b.wrapper.matches(':hover'));
if (!listHovered && !toggleWrapperHovered && !keywordBtnHovered) {
this.hideMatchList();
this._listShownFromHover = false;
}
}, 300);
}
});
this.floatingToggle.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
e.preventDefault();
const rect = this.floatingToggleWrapper.getBoundingClientRect();
const startX = e.clientX;
const startY = e.clientY;
const startLeft = rect.left;
const startTop = rect.top;
const currentWidth = rect.width;
this.floatingToggle.style.cursor = 'grabbing';
this.floatingToggle.style.transform = 'scale(1.1)';
const handleMouseMove = (moveEvent) => {
const deltaX = moveEvent.clientX - startX;
const deltaY = moveEvent.clientY - startY;
let newLeft = startLeft + deltaX;
let newTop = startTop + deltaY;
newLeft = Math.max(0, Math.min(newLeft, window.innerWidth - currentWidth));
newTop = Math.max(0, Math.min(newTop, window.innerHeight - 30));
this.floatingToggleWrapper.style.left = `${newLeft}px`;
this.floatingToggleWrapper.style.right = 'auto';
this.floatingToggleWrapper.style.top = `${newTop}px`;
// Move match list with the toggle if it's visible and triggered by toggle
if (this._isListVisible && this._listTriggerElement === this.floatingToggleWrapper) {
this.positionListNearFloatingToggle();
}
};
const handleMouseUp = (upEvent) => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
this.floatingToggle.style.cursor = 'pointer';
this.floatingToggle.style.transform = 'scale(1)';
const deltaX = Math.abs(upEvent.clientX - startX);
const deltaY = Math.abs(upEvent.clientY - startY);
if (deltaX < 5 && deltaY < 5) {
this.settings.enableSelectionMatch = !this.settings.enableSelectionMatch;
this.saveSettings();
this.updateFloatingToggleStyle();
if (this.currentSelection) {
if (this.settings.enableSelectionMatch) {
this.highlightMatches();
} else {
this.hideMatchList();
this.updateFloatingToggleBadge(0, 0);
}
}
}
const wrapperRect = this.floatingToggleWrapper.getBoundingClientRect();
const toggleCenterX = wrapperRect.left + wrapperRect.width / 2;
const isRightSide = toggleCenterX > window.innerWidth / 2;
if (isRightSide) {
const rightVal = window.innerWidth - wrapperRect.right;
this.floatingToggleWrapper.style.right = `${rightVal}px`;
this.floatingToggleWrapper.style.left = 'auto';
this.settings.floatingToggleRight = rightVal;
delete this.settings.floatingToggleX;
} else {
this.settings.floatingToggleX = parseInt(this.floatingToggleWrapper.style.left);
delete this.settings.floatingToggleRight;
}
this.settings.floatingToggleY = parseInt(this.floatingToggleWrapper.style.top);
this.saveSettings();
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
});
this.floatingToggle.addEventListener('contextmenu', (e) => {
e.preventDefault();
e.stopPropagation();
this.showFloatingContextMenu(e.clientX, e.clientY);
});
this.floatingToggle.addEventListener('wheel', (e) => {
if (e.ctrlKey) {
e.preventDefault();
const delta = e.deltaY > 0 ? -1 : 1;
let newSize = (this.settings.floatingToggleSize || 20) + delta;
newSize = Math.max(10, Math.min(40, newSize));
this.settings.floatingToggleSize = newSize;
if (this.floatingToggleText) {
this.floatingToggleText.style.fontSize = `${Math.max(10, newSize * 0.55)}px`;
this.settings.floatingToggleFontSize = Math.max(10, newSize * 0.55);
}
this.saveSettings();
} else if (e.altKey) {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.05 : 0.05;
let newOpacity = (this.settings.floatingToggleOpacity || 0.6) + delta;
newOpacity = Math.max(0.1, Math.min(1, newOpacity));
this.settings.floatingToggleOpacity = newOpacity;
this.floatingToggle.style.opacity = newOpacity.toString();
this.saveSettings();
}
}, { passive: false });
this.floatingToggle.addEventListener('dblclick', (e) => {
e.preventDefault();
this.floatingToggle.remove();
this.floatingToggle = null;
this.floatingToggleText = null;
this.settings.floatingToggleVisible = false;
this.saveSettings();
});
this.floatingToggleWrapper.appendChild(this.floatingToggle);
document.body.appendChild(this.floatingToggleWrapper);
}
updateFloatingToggleStyle() {
if (!this.floatingToggle) return;
const textEl = this.floatingToggleText;
if (!textEl) return;
const toggleText = this.settings.floatingToggleText || 'Swift';
textEl.textContent = toggleText;
// 优先从data.json获取当前激活样式
const activeStyle = this.getActiveFloatingStyle();
const currentStyleClass = activeStyle ? (activeStyle.styleClass || '') : (this.settings.floatingToggleStyleClass || '');
const currentCustomStyle = activeStyle ? (activeStyle.customStyle || '') : (this.settings.floatingToggleCustomStyle || '');
const hasCustomStyle = !!(currentStyleClass || currentCustomStyle);
if (hasCustomStyle) {
// When custom style is applied, remove inline styles so CSS classes take full control
const fontSize = this.settings.floatingToggleFontSize || 11;
const opacity = this.settings.floatingToggleOpacity ?? 0.6;
// Reset toggle inline styles to minimal, let CSS classes control appearance
this.floatingToggle.style.cssText = `
position: relative;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
flex-shrink: 0;
opacity: ${opacity};
transition: transform 0.15s ease, opacity 0.2s ease;
`;
// Reset text inline styles, let CSS classes control appearance
textEl.style.cssText = `
font-size: ${fontSize}px;
white-space: nowrap;
line-height: 1;
padding: 0;
pointer-events: none;
${!this.settings.enableSelectionMatch ? 'text-decoration: line-through; text-decoration-color: rgba(240, 100, 120, 0.7);' : ''}
`;
} else {
// Default styles when no custom style is applied
const fontSize = this.settings.floatingToggleFontSize || 11;
const paddingH = this.settings.floatingTogglePaddingH ?? 10;
const paddingV = this.settings.floatingTogglePaddingV ?? 2;
const opacity = this.settings.floatingToggleOpacity ?? 0.6;
this.floatingToggle.style.cssText = `
position: relative;
padding: ${paddingV}px ${paddingH}px;
border-radius: 9999px;
background-color: rgba(240, 100, 120, 0.08);
box-shadow: inset 0 0 0 1px rgba(240, 120, 100, 0.4), 0 2px 10px rgba(0, 0, 0, 0.05);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
flex-shrink: 0;
opacity: ${opacity};
transition: transform 0.15s ease, opacity 0.2s ease;
`;
textEl.style.cssText = `
font-weight: 800;
background: linear-gradient(135deg, #f2709c, #ff9472, #f5af19, #f2709c);
background-size: 250% 100%;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
filter: drop-shadow(0 0 6px rgba(240, 100, 120, 0.7)) drop-shadow(0 0 12px rgba(245, 150, 50, 0.5));
animation: hp-twilight-move 3.5s linear infinite;
font-size: ${fontSize}px;
white-space: nowrap;
line-height: 1;
padding: 0;
pointer-events: none;
${!this.settings.enableSelectionMatch ? 'text-decoration: line-through; text-decoration-color: rgba(240, 100, 120, 0.7);' : ''}
`;
}
// Apply style class
// Remove previous style classes
this.floatingToggle.className = this.floatingToggle.className
.split(' ')
.filter(c => !c.startsWith('swift-match-floating-style-'))
.join(' ')
.trim();
if (currentStyleClass) {
this.floatingToggle.classList.add(`swift-match-floating-style-${currentStyleClass}`);
}
// Apply custom style
this.applyFloatingCustomStyle();
// Only apply firstClass from CSS when no explicit styleClass is selected
if (!currentStyleClass && currentCustomStyle) {
const firstClass = currentCustomStyle.match(/\.([a-zA-Z_-][\w-]*)/);
if (firstClass) {
this.floatingToggle.classList.add(`swift-match-floating-style-${firstClass[1]}`);
}
}
// Update badge visibility based on enableSelectionMatch
if (!this.settings.enableSelectionMatch) {
this._listShownFromHover = false;
const toggleText = this.settings.floatingToggleText || 'Swift';
textEl.textContent = toggleText;
} else if (this._cachedMatchList && this._cachedMatchList.size > 0) {
this.updateFloatingToggleBadge(this._cachedMatchList.size, this._pendingMatchCount || 0);
}
}
showFloatingContextMenu(x, y) {
this.closeFloatingContextMenu();
const menu = document.createElement('div');
menu.className = 'swift-match-context-menu';
menu.style.cssText = `
position: fixed;
z-index: 100001;
background: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
border-radius: 6px;
padding: 4px 0;
min-width: 140px;
box-shadow: 0 4px 16px rgba(0,0,0,0.15);
font-size: 12px;
`;
const items = [
{ label: t('editFloating'), action: () => { this.closeFloatingContextMenu(); this.showFloatingEditPanel(); } },
{ label: t('settings'), action: () => { this.closeFloatingContextMenu(); const mockEvent = { clientX: x, clientY: y, preventDefault: () => {}, stopPropagation: () => {} }; this.showSettingsPanel(mockEvent); } },
{ type: 'separator' },
{ label: t('hideFloating'), action: () => { this.closeFloatingContextMenu(); if (this.floatingToggleWrapper) { this.floatingToggleWrapper.remove(); this.floatingToggleWrapper = null; } this.floatingToggle = null; this.floatingToggleText = null; this.floatingSearchBox = null; this.settings.floatingToggleVisible = false; this.saveSettings(); } }
];
items.forEach(item => {
if (item.type === 'separator') {
const sep = document.createElement('div');
sep.style.cssText = 'height:1px;background:var(--background-modifier-border);margin:4px 8px;';
menu.appendChild(sep);
return;
}
const el = document.createElement('div');
el.textContent = item.label;
el.style.cssText = `
padding: 6px 16px;
cursor: pointer;
color: var(--text-normal);
white-space: nowrap;
border-radius: 3px;
margin: 0 4px;
`;
el.addEventListener('mouseenter', () => { el.style.background = 'var(--background-modifier-hover)'; });
el.addEventListener('mouseleave', () => { el.style.background = ''; });
el.addEventListener('click', (e) => { e.stopPropagation(); item.action(); });
menu.appendChild(el);
});
// Position menu
const menuW = 160, menuH = items.length * 32;
let left = x, top = y;
if (left + menuW > window.innerWidth) left = window.innerWidth - menuW - 8;
if (top + menuH > window.innerHeight) top = window.innerHeight - menuH - 8;
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
document.body.appendChild(menu);
this._floatingContextMenu = menu;
setTimeout(() => {
this._floatingContextMenuCloser = (e) => {
if (!menu.contains(e.target)) this.closeFloatingContextMenu();
};
document.addEventListener('mousedown', this._floatingContextMenuCloser);
}, 50);
}
closeFloatingContextMenu() {
if (this._floatingContextMenuCloser) {
document.removeEventListener('mousedown', this._floatingContextMenuCloser);
this._floatingContextMenuCloser = null;
}
if (this._floatingContextMenu) {
this._floatingContextMenu.remove();
this._floatingContextMenu = null;
}
}
showFloatingEditPanel() {
this.closeFloatingEditPanel();
const { Modal } = require('obsidian');
const modal = new Modal(this.app);
modal.titleEl.textContent = t('editTitle');
modal.contentEl.addClass('swift-match-edit-modal');
const currentLabel = this.settings.floatingToggleText || 'Swift';
// 横向排列:显示文字、字体大小、水平内边距、垂直内边距、透明度
const inlineRow = modal.contentEl.createDiv();
inlineRow.style.cssText = 'display:flex;gap:10px;margin-bottom:12px;flex-wrap:wrap;';
const labelDiv = inlineRow.createDiv();
labelDiv.style.cssText = 'flex:1;min-width:100px;';
const labelName = labelDiv.createEl('label');
labelName.textContent = t('toggleText');
labelName.style.cssText = 'display:block;margin-bottom:4px;font-weight:bold;font-size:0.9em;';
const labelInput = labelDiv.createEl('input');
labelInput.type = 'text';
labelInput.placeholder = 'Swift';
labelInput.value = currentLabel;
labelInput.style.cssText = 'width:100%;padding:8px;border:1px solid var(--background-modifier-border);border-radius:4px;background:var(--background-primary);color:var(--text-normal);font-size:14px;';
const fontSizeDiv = inlineRow.createDiv();
fontSizeDiv.style.cssText = 'min-width:70px;';
const fontSizeLabel = fontSizeDiv.createEl('label');
fontSizeLabel.textContent = t('toggleFontSize');
fontSizeLabel.style.cssText = 'display:block;margin-bottom:4px;font-weight:bold;font-size:0.9em;';
const fontSizeInput = fontSizeDiv.createEl('input');
fontSizeInput.type = 'number';
fontSizeInput.min = 8; fontSizeInput.max = 24; fontSizeInput.step = 1;
fontSizeInput.value = this.settings.floatingToggleFontSize || 11;
fontSizeInput.style.cssText = 'width:70px;padding:8px;border:1px solid var(--background-modifier-border);border-radius:4px;background:var(--background-primary);color:var(--text-normal);font-size:14px;';
const padHDiv = inlineRow.createDiv();
padHDiv.style.cssText = 'min-width:70px;';
const padHLabel = padHDiv.createEl('label');
padHLabel.textContent = t('togglePaddingH');
padHLabel.style.cssText = 'display:block;margin-bottom:4px;font-weight:bold;font-size:0.9em;';
const padHInput = padHDiv.createEl('input');
padHInput.type = 'number'; padHInput.min = 0; padHInput.max = 40; padHInput.step = 1;
padHInput.value = this.settings.floatingTogglePaddingH ?? 10;
padHInput.style.cssText = 'width:70px;padding:8px;border:1px solid var(--background-modifier-border);border-radius:4px;background:var(--background-primary);color:var(--text-normal);font-size:14px;';
const padVDiv = inlineRow.createDiv();
padVDiv.style.cssText = 'min-width:70px;';
const padVLabel = padVDiv.createEl('label');
padVLabel.textContent = t('togglePaddingV');
padVLabel.style.cssText = 'display:block;margin-bottom:4px;font-weight:bold;font-size:0.9em;';
const padVInput = padVDiv.createEl('input');
padVInput.type = 'number'; padVInput.min = 0; padVInput.max = 20; padVInput.step = 1;
padVInput.value = this.settings.floatingTogglePaddingV ?? 2;
padVInput.style.cssText = 'width:70px;padding:8px;border:1px solid var(--background-modifier-border);border-radius:4px;background:var(--background-primary);color:var(--text-normal);font-size:14px;';
const opacityDiv = inlineRow.createDiv();
opacityDiv.style.cssText = 'min-width:70px;';
const opacityLabel = opacityDiv.createEl('label');
opacityLabel.textContent = t('toggleOpacity');
opacityLabel.style.cssText = 'display:block;margin-bottom:4px;font-weight:bold;font-size:0.9em;';
const opacityInput = opacityDiv.createEl('input');
opacityInput.type = 'number'; opacityInput.min = 0.1; opacityInput.max = 1; opacityInput.step = 0.05;
opacityInput.value = this.settings.floatingToggleOpacity ?? 0.6;
opacityInput.style.cssText = 'width:70px;padding:8px;border:1px solid var(--background-modifier-border);border-radius:4px;background:var(--background-primary);color:var(--text-normal);font-size:14px;';
// 自定义样式区域一个CSS文本框 + 点击选择预览
const stylesHeader = modal.contentEl.createDiv();
stylesHeader.style.cssText = 'display:flex;align-items:baseline;gap:8px;margin-bottom:4px;';
const stylesTitle = stylesHeader.createEl('label');
stylesTitle.textContent = t('customStyle');
stylesTitle.style.cssText = 'font-weight:bold;font-size:0.9em;';
const stylesLink = stylesHeader.createEl('a');
stylesLink.textContent = t('getMoreStyles');
stylesLink.href = 'https://github.com/dlsdgj/obsidian-regex-css-highlighter/discussions/1';
stylesLink.target = '_blank';
stylesLink.style.cssText = 'font-size:0.75em;color:var(--text-accent);';
const stylesHint = modal.contentEl.createEl('div');
stylesHint.textContent = t('styleHint');
stylesHint.style.cssText = 'font-size:0.75em;color:var(--text-muted);margin-bottom:4px;';
// 注入预览用的style标签
let previewStyleEl = document.getElementById('swift-match-preview-style');
if (!previewStyleEl) {
previewStyleEl = document.createElement('style');
previewStyleEl.id = 'swift-match-preview-style';
document.head.appendChild(previewStyleEl);
}
// CSS文本框
const cssTextarea = modal.contentEl.createEl('textarea');
cssTextarea.value = this.settings.floatingToggleCustomStyle || '';
cssTextarea.placeholder = `.Swift {\n background: transparent;\n color: #534ab7;\n border: 1px dashed #7f77dd;\n border-radius: 3px;\n padding: 1px 5px;\n}\n\n.Swift2 {\n background: #e8f5e9;\n color: #2e7d32;\n border-radius: 12px;\n padding: 2px 12px;\n}`;
cssTextarea.rows = 10;
cssTextarea.style.cssText = 'width:100%;padding:8px;border:1px solid var(--background-modifier-border);border-radius:4px;background:var(--background-primary);color:var(--text-normal);font-size:12px;font-family:monospace;resize:vertical;line-height:1.5;margin-bottom:8px;';
// 预览选择区域
const previewAreaLabel = modal.contentEl.createEl('div');
previewAreaLabel.textContent = t('preview') + ' - ' + t('clickToSelect') + '';
previewAreaLabel.style.cssText = 'font-weight:600;margin-bottom:6px;font-size:0.9em;';
const previewArea = modal.contentEl.createDiv();
previewArea.style.cssText = 'display:flex;flex-wrap:wrap;gap:10px;margin-bottom:16px;padding:12px;border-radius:6px;border:1px solid var(--background-modifier-border);background:var(--background-secondary);';
// 当前选中的class
let selectedClass = this.settings.floatingToggleStyleClass || '';
// 解析CSS中的class名
const parseCSSClasses = (cssText) => {
const classes = [];
const regex = /\.([a-zA-Z0-9_-]+)(?:::[\w-]+|:[\w-]+)*\s*\{/g;
let match;
const seen = new Set();
while ((match = regex.exec(cssText)) !== null) {
const cls = match[1];
if (!seen.has(cls)) {
seen.add(cls);
classes.push(cls);
}
}
return classes;
};
// 构建预览
const buildPreviews = () => {
previewArea.empty();
const cssText = cssTextarea.value;
// 作用域化CSS并注入预览
let scoped = cssText.replace(/\.([a-zA-Z_-][\w-]*)/g, '.swift-match-floating-style-$1');
previewStyleEl.textContent = scoped;
const classes = parseCSSClasses(cssText);
if (classes.length === 0) {
const hint = previewArea.createEl('div');
hint.textContent = t('noStyles');
hint.style.cssText = 'color:var(--text-muted);font-size:12px;';
return;
}
classes.forEach(cls => {
const wrapper = previewArea.createDiv();
wrapper.style.cssText = `
display:flex;flex-direction:column;align-items:center;gap:4px;
cursor:pointer;padding:6px;border-radius:6px;
border:2px solid ${cls === selectedClass ? 'var(--interactive-accent)' : 'transparent'};
transition:border-color 0.15s;
`;
wrapper.addEventListener('mouseenter', () => {
if (cls !== selectedClass) wrapper.style.borderColor = 'var(--background-modifier-border)';
});
wrapper.addEventListener('mouseleave', () => {
wrapper.style.borderColor = cls === selectedClass ? 'var(--interactive-accent)' : 'transparent';
});
const previewEl = wrapper.createEl('div');
previewEl.className = `swift-match-floating-style-${cls}`;
previewEl.textContent = labelInput.value.trim() || currentLabel;
previewEl.style.cssText = 'position:static;cursor:pointer;z-index:auto;opacity:1;';
wrapper.appendChild(previewEl);
const nameEl = wrapper.createEl('div');
nameEl.textContent = `.${cls}`;
nameEl.style.cssText = 'font-size:10px;color:var(--text-muted);font-family:monospace;';
// 默认标记
if (cls === selectedClass) {
const defaultBadge = wrapper.createEl('div');
defaultBadge.textContent = t('defaultStyle');
defaultBadge.style.cssText = 'font-size:9px;color:var(--interactive-accent);font-weight:600;';
}
wrapper.addEventListener('click', () => {
selectedClass = cls;
buildPreviews();
});
previewArea.appendChild(wrapper);
});
};
buildPreviews();
// 实时更新预览
const updatePreview = () => { buildPreviews(); };
labelInput.addEventListener('input', updatePreview);
cssTextarea.addEventListener('input', updatePreview);
// 按钮容器
const buttonContainer = modal.contentEl.createEl('div');
buttonContainer.style.cssText = 'display:flex;justify-content:flex-end;gap:8px;';
const cancelBtn = buttonContainer.createEl('button');
cancelBtn.textContent = t('cancel');
cancelBtn.style.padding = '6px 16px';
cancelBtn.addEventListener('click', () => modal.close());
const confirmBtn = buttonContainer.createEl('button');
confirmBtn.textContent = t('confirm');
confirmBtn.style.cssText = 'padding:6px 16px;background-color:var(--interactive-accent);color:white;border:none;border-radius:4px;cursor:pointer;';
confirmBtn.addEventListener('click', async () => {
this.settings.floatingToggleText = labelInput.value.trim() || 'Swift';
this.settings.floatingToggleFontSize = parseInt(fontSizeInput.value) || 11;
this.settings.floatingTogglePaddingH = parseInt(padHInput.value) || 10;
this.settings.floatingTogglePaddingV = parseInt(padVInput.value) || 2;
this.settings.floatingToggleOpacity = parseFloat(opacityInput.value) || 0.6;
this.settings.floatingToggleStyleClass = selectedClass;
this.settings.floatingToggleCustomStyle = cssTextarea.value.trim();
// 保存到data.json
this.floatingStyles = [{
id: this.floatingDefaultStyleId || Date.now().toString(),
name: 'default',
styleClass: selectedClass,
customStyle: cssTextarea.value.trim()
}];
this.floatingDefaultStyleId = this.floatingStyles[0].id;
await this.saveSettings();
await this.saveFloatingStyleData();
this.applyFloatingCustomStyle();
this.updateFloatingToggleStyle();
if (this._applySettingsBtnStyle) this._applySettingsBtnStyle();
modal.close();
});
modal.open();
this._floatingEditModal = modal;
}
applyFloatingCustomStyle() {
const existingEl = document.getElementById('swift-match-custom-style');
if (existingEl) existingEl.remove();
// 优先从data.json获取当前激活样式
const activeStyle = this.getActiveFloatingStyle();
const customStyle = activeStyle ? (activeStyle.customStyle || '') : (this.settings.floatingToggleCustomStyle || '');
if (!customStyle.trim()) return;
const styleEl = document.createElement('style');
styleEl.id = 'swift-match-custom-style';
let scoped = customStyle.replace(/\.([a-zA-Z_-][\w-]*)/g, '.swift-match-floating-style-$1');
styleEl.textContent = scoped;
document.head.appendChild(styleEl);
}
closeFloatingEditPanel() {
if (this._floatingEditModal) {
this._floatingEditModal.close();
this._floatingEditModal = null;
}
// 清理所有预览样式
document.querySelectorAll('[id^="swift-match-preview-style-"]').forEach(el => el.remove());
const previewEl = document.getElementById('swift-match-preview-style');
if (previewEl) previewEl.remove();
}
createFloatingKeywordButton(keyword, fileMap, matchCount) {
// Remove existing button for the same keyword
this.removeFloatingKeywordButton(keyword);
// Cache search results
if (fileMap && fileMap.size > 0) {
this._recentSearchCaches[keyword] = { fileMap, matchCount };
}
const wrapper = document.createElement('div');
wrapper.className = 'swift-match-keyword-btn-wrapper';
wrapper.style.cssText = `
position: fixed;
z-index: 99998;
display: flex;
align-items: center;
user-select: none;
left: 50px;
top: ${100 + this._floatingKeywordButtons.length * 36}px;
`;
const btn = document.createElement('div');
btn.className = 'swift-match-keyword-btn';
btn.style.cssText = `
position: relative;
padding: 2px 10px;
border-radius: 9999px;
background-color: rgba(240, 100, 120, 0.08);
box-shadow: inset 0 0 0 1px rgba(240, 120, 100, 0.4), 0 2px 10px rgba(0, 0, 0, 0.05);
cursor: grab;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
opacity: 0.7;
transition: transform 0.15s ease, opacity 0.2s ease;
`;
const textEl = document.createElement('span');
const displayText = keyword.length > 12 ? keyword.substring(0, 12) + '…' : keyword;
textEl.style.cssText = `
font-weight: 800;
background: linear-gradient(135deg, #f2709c, #ff9472, #f5af19, #f2709c);
background-size: 250% 100%;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
filter: drop-shadow(0 0 6px rgba(240, 100, 120, 0.7)) drop-shadow(0 0 12px rgba(245, 150, 50, 0.5));
animation: hp-twilight-move 3.5s linear infinite;
font-size: 11px;
white-space: nowrap;
line-height: 1;
padding: 0;
pointer-events: none;
`;
textEl.textContent = displayText;
btn.appendChild(textEl);
wrapper.appendChild(btn);
document.body.appendChild(wrapper);
const btnData = {
wrapper,
btn,
textEl,
term: keyword,
fileMap,
matchCount,
position: { left: 50, top: 100 + this._floatingKeywordButtons.length * 36 }
};
this._floatingKeywordButtons.push(btnData);
// Apply saved styles if any
if (this.settings.keywordButtonStyles?.[keyword]) {
this.applyKeywordButtonStyle(keyword);
}
// Persist floating keyword data
this._updateFloatingKeywordsData();
// Hover: show search window
let hoverTimer = null;
wrapper.addEventListener('mouseenter', () => {
btn.style.opacity = '1';
wrapper.style.zIndex = '100000';
hoverTimer = setTimeout(() => {
this._listUserDismissed = false;
const cached = this._recentSearchCaches[keyword];
if (cached && cached.fileMap && cached.fileMap.size > 0) {
this._cachedMatchList = cached.fileMap;
this._cachedMatchListKey = keyword;
this._pendingMatchCount = cached.matchCount;
this._pendingShowList = { searchText: keyword, matchCount: cached.matchCount };
this._listTriggerElement = wrapper;
this._isListVisible = true;
this.renderMatchList(cached.fileMap, cached.matchCount, false);
this._listShownFromHover = true;
this.positionListNearElement(wrapper);
} else {
this._listTriggerElement = wrapper;
this.currentSelection = keyword;
this.highlightMatches();
}
}, 200);
});
wrapper.addEventListener('mouseleave', () => {
btn.style.opacity = '0.7';
wrapper.style.zIndex = '99998';
if (hoverTimer) {
clearTimeout(hoverTimer);
hoverTimer = null;
}
if (this._isListVisible && this._listShownFromHover) {
setTimeout(() => {
const listHovered = this.matchList && this.matchList.matches(':hover');
const btnHovered = wrapper.matches(':hover');
if (!listHovered && !btnHovered) {
this.hideMatchList();
this._listShownFromHover = false;
}
}, 100);
}
});
// Drag support
let isDragging = false;
let hasDragged = false;
let startX, startY, startLeft, startTop;
const onDragMove = (e) => {
if (!isDragging) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) hasDragged = true;
let newLeft = startLeft + dx;
let newTop = startTop + dy;
newLeft = Math.max(0, Math.min(newLeft, window.innerWidth - wrapper.offsetWidth));
newTop = Math.max(0, Math.min(newTop, window.innerHeight - 30));
wrapper.style.left = `${newLeft}px`;
wrapper.style.top = `${newTop}px`;
btnData.position.left = newLeft;
btnData.position.top = newTop;
// Move match list with the keyword button if it's visible and triggered by this button
if (this._isListVisible && this._listTriggerElement === wrapper) {
this.positionListNearElement(wrapper);
}
};
const onDragEnd = () => {
if (!isDragging) return;
isDragging = false;
btn.style.cursor = 'grab';
btn.style.transform = '';
document.removeEventListener('mousemove', onDragMove);
document.removeEventListener('mouseup', onDragEnd);
};
btn.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
isDragging = true;
hasDragged = false;
startX = e.clientX;
startY = e.clientY;
const rect = wrapper.getBoundingClientRect();
startLeft = rect.left;
startTop = rect.top;
btn.style.cursor = 'grabbing';
btn.style.transform = 'scale(1.05)';
document.addEventListener('mousemove', onDragMove);
document.addEventListener('mouseup', onDragEnd);
});
// Right-click context menu
wrapper.addEventListener('contextmenu', (e) => {
e.preventDefault();
e.stopPropagation();
this.showKeywordContextMenu(e.clientX, e.clientY, keyword);
});
}
showKeywordContextMenu(x, y, keyword) {
this.closeFloatingContextMenu();
const menu = document.createElement('div');
menu.className = 'swift-match-context-menu';
menu.style.cssText = `
position: fixed;
z-index: 100001;
background: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
border-radius: 6px;
padding: 4px 0;
min-width: 120px;
box-shadow: 0 4px 16px rgba(0,0,0,0.15);
font-size: 12px;
`;
const items = [
{ label: t('editFloating'), action: () => { this.closeFloatingContextMenu(); this.showKeywordEditPanel(keyword); } },
{ type: 'separator' },
{ label: t('closeKeyword'), action: () => { this.closeFloatingContextMenu(); this.removeFloatingKeywordButton(keyword); } }
];
items.forEach(item => {
if (item.type === 'separator') {
const sep = document.createElement('div');
sep.style.cssText = 'height:1px;background:var(--background-modifier-border);margin:4px 8px;';
menu.appendChild(sep);
return;
}
const el = document.createElement('div');
el.textContent = item.label;
el.style.cssText = `
padding: 6px 16px;
cursor: pointer;
color: var(--text-normal);
white-space: nowrap;
border-radius: 3px;
margin: 0 4px;
`;
el.addEventListener('mouseenter', () => { el.style.background = 'var(--background-modifier-hover)'; });
el.addEventListener('mouseleave', () => { el.style.background = ''; });
el.addEventListener('click', (e) => { e.stopPropagation(); item.action(); });
menu.appendChild(el);
});
let left = x, top = y;
if (left + 140 > window.innerWidth) left = window.innerWidth - 148;
if (top + 80 > window.innerHeight) top = window.innerHeight - 88;
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
document.body.appendChild(menu);
this._floatingContextMenu = menu;
setTimeout(() => {
this._floatingContextMenuCloser = (e) => {
if (!menu.contains(e.target)) this.closeFloatingContextMenu();
};
document.addEventListener('mousedown', this._floatingContextMenuCloser);
}, 50);
}
showKeywordEditPanel(keyword) {
const { Modal } = require('obsidian');
const modal = new Modal(this.app);
modal.titleEl.textContent = `${t('editFloating')} - ${keyword}`;
modal.contentEl.addClass('swift-match-edit-modal');
const btnData = this._floatingKeywordButtons.find(b => b.term === keyword);
if (!btnData) { modal.close(); return; }
const savedStyles = this.settings.keywordButtonStyles?.[keyword] || {};
const currentStyleClass = savedStyles.styleClass || '';
const currentCustomStyle = savedStyles.customStyle || '';
const currentLabel = savedStyles.label || keyword;
// 显示文字
const labelDiv = modal.contentEl.createDiv();
labelDiv.style.marginBottom = '12px';
const labelName = labelDiv.createEl('label');
labelName.textContent = t('toggleText');
labelName.style.cssText = 'display:block;margin-bottom:4px;font-weight:bold;font-size:0.9em;';
const labelInput = labelDiv.createEl('input');
labelInput.type = 'text';
labelInput.placeholder = keyword;
labelInput.value = currentLabel;
labelInput.style.cssText = 'width:100%;padding:8px;border:1px solid var(--background-modifier-border);border-radius:4px;background:var(--background-primary);color:var(--text-normal);font-size:14px;';
// 样式类名
const styleClassDiv = modal.contentEl.createDiv();
styleClassDiv.style.marginBottom = '12px';
const styleClassName = styleClassDiv.createEl('label');
styleClassName.textContent = t('styleClass');
styleClassName.style.cssText = 'display:block;margin-bottom:4px;font-weight:bold;font-size:0.9em;';
const styleClassInput = styleClassDiv.createEl('input');
styleClassInput.type = 'text';
styleClassInput.placeholder = 'CSS class name (e.g. my-style)';
styleClassInput.value = currentStyleClass;
styleClassInput.style.cssText = 'width:100%;padding:8px;border:1px solid var(--background-modifier-border);border-radius:4px;background:var(--background-primary);color:var(--text-normal);font-size:14px;';
// 自定义样式
const customStyleDiv = modal.contentEl.createDiv();
customStyleDiv.style.marginBottom = '16px';
const customStyleName = customStyleDiv.createEl('label');
customStyleName.textContent = t('customStyle');
customStyleName.style.cssText = 'display:block;margin-bottom:4px;font-weight:bold;font-size:0.9em;';
const customStyleHint = customStyleDiv.createEl('div');
customStyleHint.textContent = t('styleHint');
customStyleHint.style.cssText = 'font-size:0.75em;color:var(--text-muted);margin-bottom:4px;';
const customStyleInput = customStyleDiv.createEl('textarea');
customStyleInput.placeholder = `.keyword-btn {\n background: transparent;\n color: #534ab7;\n border: 1px dashed #7f77dd;\n border-radius: 3px;\n padding: 1px 5px;\n}`;
customStyleInput.value = currentCustomStyle;
customStyleInput.style.cssText = 'width:100%;min-height:160px;padding:8px;border:1px solid var(--background-modifier-border);border-radius:4px;background:var(--background-primary);color:var(--text-normal);font-size:13px;font-family:monospace;resize:vertical;';
// 预览区域
const previewDiv = modal.contentEl.createDiv();
previewDiv.style.cssText = 'margin-bottom:16px;padding:12px;border:1px dashed var(--background-modifier-border);border-radius:6px;text-align:center;';
const previewLabel = previewDiv.createEl('div');
previewLabel.textContent = t('preview');
previewLabel.style.cssText = 'font-size:0.8em;color:var(--text-muted);margin-bottom:8px;';
const previewSpan = previewDiv.createEl('span');
previewSpan.textContent = currentLabel;
previewSpan.style.cssText = 'display:inline-block;padding:4px 8px;';
const previewStyleId = `swift-match-keyword-preview-style-${keyword.replace(/[^a-zA-Z0-9]/g, '_')}`;
const applyPreviewStyle = (styleText) => {
const existingEl = document.getElementById(previewStyleId);
if (existingEl) existingEl.remove();
if (!styleText.trim()) return;
const styleEl = document.createElement('style');
styleEl.id = previewStyleId;
let scoped = styleText.replace(/\.([a-zA-Z_-][\w-]*)/g, '.swift-match-keyword-style-$1');
styleEl.textContent = scoped;
document.head.appendChild(styleEl);
};
const updatePreview = () => {
const newLabel = labelInput.value.trim() || keyword;
const newClass = styleClassInput.value.trim();
const newCustomStyle = customStyleInput.value.trim();
previewSpan.textContent = newLabel;
previewSpan.className = '';
previewSpan.style.cssText = 'display:inline-block;padding:4px 8px;';
if (newClass) {
previewSpan.classList.add(`swift-match-keyword-style-${newClass}`);
}
if (newCustomStyle) {
applyPreviewStyle(newCustomStyle);
const firstClass = newCustomStyle.match(/\.([a-zA-Z_-][\w-]*)/);
if (firstClass) {
previewSpan.classList.add(`swift-match-keyword-style-${firstClass[1]}`);
}
} else {
const existingEl = document.getElementById(previewStyleId);
if (existingEl) existingEl.remove();
}
};
labelInput.addEventListener('input', updatePreview);
styleClassInput.addEventListener('input', updatePreview);
customStyleInput.addEventListener('input', updatePreview);
updatePreview();
// 按钮容器
const buttonContainer = modal.contentEl.createEl('div');
buttonContainer.style.cssText = 'display:flex;justify-content:flex-end;gap:8px;';
const cancelBtn = buttonContainer.createEl('button');
cancelBtn.textContent = t('cancel');
cancelBtn.style.padding = '6px 16px';
cancelBtn.addEventListener('click', () => modal.close());
const confirmBtn = buttonContainer.createEl('button');
confirmBtn.textContent = t('confirm');
confirmBtn.style.cssText = 'padding:6px 16px;background-color:var(--interactive-accent);color:white;border:none;border-radius:4px;cursor:pointer;';
confirmBtn.addEventListener('click', async () => {
const newLabel = labelInput.value.trim() || keyword;
const newStyleClass = styleClassInput.value.trim();
const newCustomStyle = customStyleInput.value.trim();
// Save to settings
if (!this.settings.keywordButtonStyles) this.settings.keywordButtonStyles = {};
this.settings.keywordButtonStyles[keyword] = {
label: newLabel,
styleClass: newStyleClass,
customStyle: newCustomStyle
};
await this.saveSettings();
// Apply to button
this.applyKeywordButtonStyle(keyword);
modal.close();
});
modal.open();
// Clean up preview style on modal close
const origClose = modal.close.bind(modal);
modal.close = () => {
const previewEl = document.getElementById(previewStyleId);
if (previewEl) previewEl.remove();
origClose();
};
}
applyKeywordButtonStyle(keyword) {
const btnData = this._floatingKeywordButtons.find(b => b.term === keyword);
if (!btnData) return;
const savedStyles = this.settings.keywordButtonStyles?.[keyword] || {};
const hasCustomStyle = !!(savedStyles.styleClass || savedStyles.customStyle);
// Update label
const displayLabel = savedStyles.label || keyword;
const displayText = displayLabel.length > 12 ? displayLabel.substring(0, 12) + '…' : displayLabel;
btnData.textEl.textContent = displayText;
if (hasCustomStyle) {
// Remove inline styles that conflict with custom CSS
btnData.btn.style.cssText = `
position: relative;
cursor: grab;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
opacity: 0.7;
transition: transform 0.15s ease, opacity 0.2s ease;
`;
btnData.textEl.style.cssText = `
font-size: 11px;
white-space: nowrap;
line-height: 1;
padding: 0;
pointer-events: none;
`;
// Apply style class
btnData.btn.className = 'swift-match-keyword-btn';
if (savedStyles.styleClass) {
btnData.btn.classList.add(`swift-match-keyword-style-${savedStyles.styleClass}`);
}
// Apply custom CSS
const styleId = `swift-match-keyword-custom-style-${keyword.replace(/[^a-zA-Z0-9]/g, '_')}`;
const existingEl = document.getElementById(styleId);
if (existingEl) existingEl.remove();
if (savedStyles.customStyle) {
const styleEl = document.createElement('style');
styleEl.id = styleId;
let scoped = savedStyles.customStyle.replace(/\.([a-zA-Z_-][\w-]*)/g, '.swift-match-keyword-style-$1');
styleEl.textContent = scoped;
document.head.appendChild(styleEl);
const firstClass = savedStyles.customStyle.match(/\.([a-zA-Z_-][\w-]*)/);
if (firstClass) {
btnData.btn.classList.add(`swift-match-keyword-style-${firstClass[1]}`);
}
}
} else {
// Restore default styles
btnData.btn.style.cssText = `
position: relative;
padding: 2px 10px;
border-radius: 9999px;
background-color: rgba(240, 100, 120, 0.08);
box-shadow: inset 0 0 0 1px rgba(240, 120, 100, 0.4), 0 2px 10px rgba(0, 0, 0, 0.05);
cursor: grab;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
opacity: 0.7;
transition: transform 0.15s ease, opacity 0.2s ease;
`;
btnData.textEl.style.cssText = `
font-weight: 800;
background: linear-gradient(135deg, #f2709c, #ff9472, #f5af19, #f2709c);
background-size: 250% 100%;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
filter: drop-shadow(0 0 6px rgba(240, 100, 120, 0.7)) drop-shadow(0 0 12px rgba(245, 150, 50, 0.5));
animation: hp-twilight-move 3.5s linear infinite;
font-size: 11px;
white-space: nowrap;
line-height: 1;
padding: 0;
pointer-events: none;
`;
btnData.btn.className = 'swift-match-keyword-btn';
}
}
removeFloatingKeywordButton(keyword) {
const idx = this._floatingKeywordButtons.findIndex(b => b.term === keyword);
if (idx >= 0) {
const btnData = this._floatingKeywordButtons[idx];
if (btnData.wrapper && btnData.wrapper.parentNode) {
btnData.wrapper.parentNode.removeChild(btnData.wrapper);
}
this._floatingKeywordButtons.splice(idx, 1);
this.deleteKeywordData(keyword);
this._updateFloatingKeywordsData();
}
}
async _updateFloatingKeywordsData() {
const adapter = this.app.vault.adapter;
if (!await adapter.exists(this.keywordDir)) {
await adapter.mkdir(this.keywordDir);
}
let existingFiles = [];
try {
const listed = await adapter.list(this.keywordDir);
existingFiles = Array.isArray(listed) ? listed : (listed?.files || []);
} catch (e) {}
const savedTerms = new Set();
for (const btn of this._floatingKeywordButtons) {
savedTerms.add(this._cacheKeyToFileName(btn.term));
await this.saveKeywordData(
btn.term,
btn.position || { left: 50, top: 100 },
btn.fileMap,
btn.matchCount || 0
);
}
for (const f of existingFiles) {
const filePath = f.startsWith(this.keywordDir) ? f : `${this.keywordDir}/${f}`;
if (!filePath.endsWith('.json')) continue;
const baseName = filePath.split('/').pop().replace('.json', '');
if (!savedTerms.has(baseName)) {
await adapter.remove(filePath).catch(() => {});
}
}
}
async _restoreFloatingKeywordButtons() {
const allData = await this.loadAllKeywordData();
if (!allData || allData.length === 0) return;
for (const data of allData) {
try {
let fileMap = null;
if (data.fileMapData && data.fileMapData.length > 0) {
fileMap = await this.deserializeFileMap(data.fileMapData);
}
if (fileMap && fileMap.size > 0) {
this._recentSearchCaches[data.term] = { fileMap, matchCount: data.matchCount || 0 };
this.createFloatingKeywordButton(data.term, fileMap, data.matchCount || 0);
} else {
this.createFloatingKeywordButton(data.term, new Map(), 0);
}
const btnData = this._floatingKeywordButtons.find(b => b.term === data.term);
if (btnData && data.position) {
btnData.position = data.position;
btnData.wrapper.style.left = data.position.left + 'px';
btnData.wrapper.style.top = data.position.top + 'px';
}
} catch (e) {
console.error('Failed to restore floating keyword button:', data.term, e);
}
}
}
async _restoreRecentSearchCaches() {
const adapter = this.app.vault.adapter;
if (!await adapter.exists(this.cacheDir)) return;
try {
const listed = await adapter.list(this.cacheDir);
const files = Array.isArray(listed) ? listed : (listed?.files || []);
for (const f of files) {
const filePath = f.startsWith(this.cacheDir) ? f : `${this.cacheDir}/${f}`;
if (!filePath.endsWith('.json')) continue;
try {
const content = await adapter.read(filePath);
const data = JSON.parse(content);
if (data.fileMapData && data.fileMapData.length > 0) {
const fileMap = await this.deserializeFileMap(data.fileMapData);
if (fileMap && fileMap.size > 0) {
this._recentSearchCaches[data.term] = { fileMap, matchCount: data.matchCount || 0 };
}
}
} catch (e) {}
}
} catch (e) {}
}
positionListNearElement(el) {
if (!this.matchList) return;
const rect = el.getBoundingClientRect();
const listWidth = this.matchListSize.width || this.settings.matchListWidth || 280;
const listHeight = this.matchList.offsetHeight || (this.matchListSize.height || this.settings.matchListHeight || 400);
let left = rect.left;
let top = rect.bottom + 5;
if (left + listWidth > window.innerWidth - 10) {
left = window.innerWidth - listWidth - 10;
}
if (left < 10) left = 10;
if (top + listHeight > window.innerHeight - 10) {
const availableBelow = window.innerHeight - top - 10;
const availableAbove = rect.top - 15;
if (availableBelow >= 60) {
this.matchList.style.maxHeight = `${availableBelow}px`;
} else if (availableAbove >= 60) {
top = rect.top - Math.min(listHeight, availableAbove) - 5;
this.matchList.style.maxHeight = `${Math.min(listHeight, availableAbove)}px`;
} else {
this.matchList.style.maxHeight = `${Math.max(60, window.innerHeight - 20)}px`;
top = 10;
}
} else {
this.matchList.style.maxHeight = '';
}
this.matchList.style.left = `${left}px`;
this.matchList.style.top = `${top}px`;
}
expandFloatingSearchBox() {
if (!this.floatingSearchBox) return;
const size = this.settings.floatingToggleSize || 20;
// Dynamically determine direction based on current toggle position
const wrapperRect = this.floatingToggleWrapper.getBoundingClientRect();
const isRightSide = wrapperRect.left + wrapperRect.width / 2 > window.innerWidth / 2;
if (isRightSide) {
this.floatingSearchBox.style.left = '';
this.floatingSearchBox.style.right = '100%';
this.floatingSearchBox.style.marginLeft = '';
this.floatingSearchBox.style.marginRight = '4px';
} else {
this.floatingSearchBox.style.right = '';
this.floatingSearchBox.style.left = '100%';
this.floatingSearchBox.style.marginRight = '';
this.floatingSearchBox.style.marginLeft = '4px';
}
this.floatingSearchBox.style.width = '160px';
this.floatingSearchBox.style.padding = `0 ${size / 2}px`;
this.floatingSearchBox.style.opacity = '1';
}
collapseFloatingSearchBox() {
if (!this.floatingSearchBox) return;
// Don't collapse if search box is focused
if (document.activeElement === this.floatingSearchBox) return;
this.floatingSearchBox.style.width = '0';
this.floatingSearchBox.style.padding = '0';
this.floatingSearchBox.style.opacity = '0';
}
clampFloatingTogglePosition() {
if (!this.floatingToggleWrapper) return;
const windowW = window.innerWidth;
const windowH = window.innerHeight;
const margin = 5;
const rect = this.floatingToggleWrapper.getBoundingClientRect();
let changed = false;
const usesRight = this.floatingToggleWrapper.style.right && this.floatingToggleWrapper.style.right !== 'auto';
if (usesRight) {
let right = parseFloat(this.floatingToggleWrapper.style.right);
if (!isNaN(right) && right + rect.width > windowW - margin) {
right = Math.max(margin, windowW - rect.width - margin);
this.floatingToggleWrapper.style.right = `${right}px`;
this.settings.floatingToggleRight = right;
delete this.settings.floatingToggleX;
changed = true;
}
} else {
let left = parseFloat(this.floatingToggleWrapper.style.left);
if (!isNaN(left) && left + rect.width > windowW - margin) {
left = Math.max(margin, windowW - rect.width - margin);
this.floatingToggleWrapper.style.left = `${left}px`;
this.settings.floatingToggleX = left;
delete this.settings.floatingToggleRight;
changed = true;
}
}
let top = parseFloat(this.floatingToggleWrapper.style.top);
if (!isNaN(top) && top + rect.height > windowH - margin) {
top = Math.max(margin, windowH - rect.height - margin);
this.floatingToggleWrapper.style.top = `${top}px`;
this.settings.floatingToggleY = top;
changed = true;
}
if (changed) this.saveSettings();
}
addCommands() {
this.addCommand({
id: 'toggle-minimap',
name: 'Toggle Minimap',
callback: () => {
if (this.minimapContainer) {
const isHidden = this.minimapContainer.style.display === 'none';
this.minimapContainer.style.display = isHidden ? 'block' : 'none';
if (isHidden) {
this.applyEditorPadding();
} else {
this.removeEditorPadding();
}
}
}
});
}
_isMinimapBlacklisted() {
const leaf = this.app.workspace.activeLeaf;
if (!leaf) return false;
return this._isLeafBlacklisted(leaf);
}
_isLeafBlacklisted(leaf) {
if (!leaf) return false;
let filePath = leaf.view?.file?.path;
let viewTitle = '';
let viewType = leaf.view?.viewType || '';
if (!filePath) {
// Try getDisplayText() first (most reliable for plugin views)
if (leaf.view && typeof leaf.view.getDisplayText === 'function') {
viewTitle = leaf.view.getDisplayText()?.trim() || '';
}
// Fallback to DOM title
if (!viewTitle) {
const leafEl = leaf.containerEl || leaf.view?.containerEl;
if (leafEl) {
const navEl = leafEl.querySelector('.view-header-title');
if (navEl) {
viewTitle = navEl.textContent?.trim() || '';
}
}
}
// If title matches a vault file, use its path instead
if (viewTitle) {
const found = this.app.vault.getFiles().find(f => f.name === viewTitle || f.basename === viewTitle);
if (found) filePath = found.path;
}
}
if (!filePath && !viewTitle && !viewType) return false;
const blacklist = (this.settings.minimapBlacklist || '').split('\n').map(s => s.trim()).filter(Boolean);
for (const pattern of blacklist) {
const regex = new RegExp('^' + pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$', 'i');
if (filePath && regex.test(filePath)) return true;
if (viewTitle && regex.test(viewTitle)) return true;
if (viewType && regex.test(viewType)) return true;
}
return false;
}
_getViewInfoFromContainer(containerEl) {
// Walk up the DOM to find the leaf that owns this container
const leaves = [];
this.app.workspace.iterateRootLeaves((leaf) => { leaves.push(leaf); });
// Also check sidebar leaves
if (this.app.workspace.leftSplit) {
const leftLeaves = this.app.workspace.leftSplit.children?.flatMap(s => s.children || []) || [];
leaves.push(...leftLeaves);
}
if (this.app.workspace.rightSplit) {
const rightLeaves = this.app.workspace.rightSplit.children?.flatMap(s => s.children || []) || [];
leaves.push(...rightLeaves);
}
for (const leaf of leaves) {
if (leaf.view && leaf.view.containerEl && leaf.view.containerEl.contains(containerEl)) {
return {
filePath: leaf.view.file?.path || '',
viewTitle: (typeof leaf.view.getDisplayText === 'function' ? leaf.view.getDisplayText()?.trim() : '') || '',
viewType: leaf.view.viewType || ''
};
}
}
// Fallback: check DOM for view-header-title
const navEl = containerEl.querySelector('.view-header-title');
const viewTitle = navEl ? navEl.textContent?.trim() || '' : '';
return { filePath: '', viewTitle, viewType: '' };
}
_isViewInfoBlacklisted(info) {
const { filePath, viewTitle, viewType } = info;
if (!filePath && !viewTitle && !viewType) return false;
const blacklist = (this.settings.minimapBlacklist || '').split('\n').map(s => s.trim()).filter(Boolean);
for (const pattern of blacklist) {
const regex = new RegExp('^' + pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$', 'i');
if (filePath && regex.test(filePath)) return true;
if (viewTitle && regex.test(viewTitle)) return true;
if (viewType && regex.test(viewType)) return true;
}
return false;
}
_cleanupBlacklistedLeaves() {
// Remove minimap containers from all blacklisted leaves
const leaves = [];
this.app.workspace.iterateRootLeaves((leaf) => {
leaves.push(leaf);
});
for (const leaf of leaves) {
if (this._isLeafBlacklisted(leaf)) {
const containerEl = leaf.view?.containerEl;
if (containerEl) {
const existing = containerEl.querySelector('.minimap-container');
if (existing && existing.parentNode) {
existing.parentNode.removeChild(existing);
}
}
}
}
}
setupMinimap() {
const workspace = this.app.workspace;
const activeLeaf = workspace.activeLeaf;
if (!activeLeaf) return;
// Clean up minimap on all blacklisted leaves (handles settings changes)
this._cleanupBlacklistedLeaves();
if (this._isMinimapBlacklisted()) {
this.removeEditorPadding();
if (this.minimapContainer && this.minimapContainer.parentNode) {
this.minimapContainer.parentNode.removeChild(this.minimapContainer);
}
this.minimapContainer = null;
this.minimapContent = null;
this.slider = null;
return;
}
const editorEl = activeLeaf.view.containerEl;
if (!editorEl) return;
// Also check if the containerEl belongs to a blacklisted view
// (activeLeaf may not reflect the actual view for plugin pages like Thino)
const containerViewInfo = this._getViewInfoFromContainer(editorEl);
if (this._isViewInfoBlacklisted(containerViewInfo)) {
this.removeEditorPadding();
if (this.minimapContainer && this.minimapContainer.parentNode) {
this.minimapContainer.parentNode.removeChild(this.minimapContainer);
}
this.minimapContainer = null;
this.minimapContent = null;
this.slider = null;
return;
}
this.removeEditorPadding();
if (this.minimapContainer && this.minimapContainer.parentNode) {
this.minimapContainer.parentNode.removeChild(this.minimapContainer);
}
this.minimapContainer = document.createElement('div');
this.minimapContainer.className = 'minimap-container';
editorEl.appendChild(this.minimapContainer);
this.minimapContainer.addEventListener('contextmenu', this.boundContextMenu);
this.minimapContent = document.createElement('div');
this.minimapContent.className = 'minimap-content';
this.minimapContainer.appendChild(this.minimapContent);
this.slider = document.createElement('div');
this.slider.className = 'minimap-slider';
this.minimapContainer.appendChild(this.slider);
this.tooltip = document.createElement('div');
this.tooltip.className = 'minimap-tooltip';
this.tooltip.style.display = 'none';
document.body.appendChild(this.tooltip);
this.matchList = document.createElement('div');
this.matchList.className = 'minimap-match-list';
this.matchList.style.display = 'none';
document.body.appendChild(this.matchList);
this.previewPanel = document.createElement('div');
this.previewPanel.className = 'minimap-preview-panel';
this.previewPanel.style.display = 'none';
this.previewPanel.style.position = 'absolute';
document.body.appendChild(this.previewPanel);
this.applySettings();
this.setupListDrag();
this.setupListWheel();
this.setupSliderEvents();
this.updateMinimapContent();
this.setupScrollListener();
this.decorationField = null;
if (!this.isReadingMode()) {
this.registerEditorField();
this.setupPinnedMatchHover();
}
this.minimapContainer.addEventListener('wheel', this.boundWheel, { passive: false });
this.setupOutlineOnHover();
this.applyEditorPadding();
setTimeout(() => {
this.updateViewport();
}, 200);
}
setupOutlineOnHover() {
this._removeOutlineOnHover();
if (!this.settings.outlineOnHover || !this.minimapContainer) return;
this._outlinePanel = document.createElement('div');
this._outlinePanel.className = 'minimap-outline-panel';
this._outlinePanel.style.display = 'none';
document.body.appendChild(this._outlinePanel);
this._outlineHideTimer = null;
this._outlinePinned = false;
this._outlineOpacity = 0.85;
this._outlineSvgLines = null;
const pinBtn = document.createElement('div');
pinBtn.className = 'minimap-outline-pin';
pinBtn.textContent = '📌';
pinBtn.title = '固定大纲';
this._outlinePinBtn = pinBtn;
const showOutline = () => {
if (!this.settings.outlineOnHover) return;
clearTimeout(this._outlineHideTimer);
this._outlineHideTimer = null;
this._buildOutlineContent();
this._positionOutlinePanel();
this._outlinePanel.style.display = 'flex';
this._highlightCurrentHeading();
};
const hideOutline = () => {
if (this._outlinePinned) return;
this._outlineHideTimer = setTimeout(() => {
if (this._outlinePanel && !this._outlinePinned) this._outlinePanel.style.display = 'none';
}, 200);
};
const keepOutline = () => {
clearTimeout(this._outlineHideTimer);
this._outlineHideTimer = null;
};
pinBtn.addEventListener('click', (e) => {
e.stopPropagation();
this._outlinePinned = !this._outlinePinned;
pinBtn.classList.toggle('pinned', this._outlinePinned);
pinBtn.title = this._outlinePinned ? '取消固定' : '固定大纲';
if (this._outlinePinned && this.settings.outlinePinnedMargin) {
this._applyOutlineMargin();
} else {
this._removeOutlineMargin();
}
});
this._boundOutlineWheel = (e) => {
if (e.ctrlKey) {
e.preventDefault();
e.stopPropagation();
const step = e.deltaY > 0 ? -0.05 : 0.05;
this._outlineOpacity = Math.max(0.2, Math.min(1, this._outlineOpacity + step));
this._outlinePanel.style.opacity = this._outlineOpacity;
}
};
this._boundOutlineEnter = showOutline;
this._boundOutlineLeave = hideOutline;
this._boundOutlinePanelEnter = keepOutline;
this._boundOutlinePanelLeave = hideOutline;
this.minimapContainer.addEventListener('mouseenter', this._boundOutlineEnter);
this.minimapContainer.addEventListener('mouseleave', this._boundOutlineLeave);
this._outlinePanel.addEventListener('mouseenter', this._boundOutlinePanelEnter);
this._outlinePanel.addEventListener('mouseleave', this._boundOutlinePanelLeave);
this._outlinePanel.addEventListener('wheel', this._boundOutlineWheel, { passive: false });
}
_removeOutlineOnHover() {
if (this._outlinePanel) {
this._outlinePanel.removeEventListener('mouseenter', this._boundOutlinePanelEnter);
this._outlinePanel.removeEventListener('mouseleave', this._boundOutlinePanelLeave);
this._outlinePanel.removeEventListener('wheel', this._boundOutlineWheel);
if (this._outlinePanel.parentNode) this._outlinePanel.parentNode.removeChild(this._outlinePanel);
this._outlinePanel = null;
}
if (this.minimapContainer) {
this.minimapContainer.removeEventListener('mouseenter', this._boundOutlineEnter);
this.minimapContainer.removeEventListener('mouseleave', this._boundOutlineLeave);
}
clearTimeout(this._outlineHideTimer);
this._outlineHideTimer = null;
this._outlinePinned = false;
this._removeOutlineMargin();
}
_buildOutlineContent() {
if (!this._outlinePanel) return;
const file = this.app.workspace.activeLeaf?.view?.file;
if (!file) return;
const cache = this.app.metadataCache.getFileCache(file);
if (!cache || !cache.headings || cache.headings.length === 0) {
this._outlinePanel.innerHTML = '';
const emptyDiv = document.createElement('div');
emptyDiv.className = 'minimap-outline-empty';
emptyDiv.textContent = '无标题';
this._outlinePanel.appendChild(emptyDiv);
if (this._outlinePinBtn) this._outlinePanel.insertBefore(this._outlinePinBtn, emptyDiv);
return;
}
const headings = cache.headings;
const container = document.createElement('div');
container.className = 'minimap-outline-items';
for (const h of headings) {
const level = h.level || 1;
const text = h.heading || '';
const item = document.createElement('div');
item.className = 'minimap-outline-item';
item.dataset.level = level;
item.dataset.heading = text;
item.style.paddingLeft = `${(level - 1) * 16 + 8}px`;
item.textContent = text;
container.appendChild(item);
}
this._outlinePanel.innerHTML = '';
if (this._outlinePinBtn) this._outlinePanel.appendChild(this._outlinePinBtn);
this._outlinePanel.appendChild(container);
this._outlinePanel.querySelectorAll('.minimap-outline-item').forEach(item => {
item.addEventListener('click', () => {
const headingText = item.dataset.heading;
this._jumpToHeadingInActiveDoc(headingText);
});
});
requestAnimationFrame(() => this._drawOutlineCurves());
}
_drawOutlineCurves() {
if (this._outlineSvgLines) {
this._outlineSvgLines.remove();
this._outlineSvgLines = null;
}
const container = this._outlinePanel?.querySelector('.minimap-outline-items');
if (!container) return;
const items = container.querySelectorAll('.minimap-outline-item');
if (items.length < 2) return;
const containerRect = container.getBoundingClientRect();
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('class', 'minimap-outline-svg');
svg.style.position = 'absolute';
svg.style.top = '0';
svg.style.left = '0';
svg.style.width = `${containerRect.width}px`;
svg.style.height = `${containerRect.height}px`;
svg.style.pointerEvents = 'none';
svg.style.zIndex = '0';
const itemData = [];
items.forEach((item) => {
const rect = item.getBoundingClientRect();
const level = parseInt(item.dataset.level) || 1;
itemData.push({ el: item, level, top: rect.top - containerRect.top, h: rect.height });
});
const maxLevel = Math.max(...itemData.map(d => d.level));
const indentPx = 16;
const lineX = (level) => (level - 1) * indentPx + 5;
const branchLen = 8;
const strokeColor = 'var(--text-faint)';
const strokeOpacity = '0.3';
for (let lv = 1; lv < maxLevel; lv++) {
const x = lineX(lv);
let runStart = null;
for (let i = 0; i <= itemData.length; i++) {
const item = itemData[i];
const inScope = item && item.level > lv;
if (inScope && runStart === null) {
runStart = item.top;
}
if (!inScope && runStart !== null) {
const endItem = itemData[i - 1];
const y1 = runStart;
const y2 = endItem.top + endItem.h;
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('x1', x);
line.setAttribute('y1', y1);
line.setAttribute('x2', x);
line.setAttribute('y2', y2);
line.setAttribute('stroke', strokeColor);
line.setAttribute('stroke-width', '1');
line.setAttribute('opacity', strokeOpacity);
svg.appendChild(line);
runStart = null;
}
}
}
for (const d of itemData) {
if (d.level <= 1) continue;
const x1 = lineX(d.level - 1);
const x2 = x1 + branchLen;
const y = d.top + d.h / 2;
const branch = document.createElementNS('http://www.w3.org/2000/svg', 'line');
branch.setAttribute('x1', x1);
branch.setAttribute('y1', y);
branch.setAttribute('x2', x2);
branch.setAttribute('y2', y);
branch.setAttribute('stroke', strokeColor);
branch.setAttribute('stroke-width', '1');
branch.setAttribute('opacity', strokeOpacity);
svg.appendChild(branch);
}
container.style.position = 'relative';
container.appendChild(svg);
this._outlineSvgLines = svg;
}
_positionOutlinePanel() {
if (!this._outlinePanel || !this.minimapContainer) return;
const rect = this.minimapContainer.getBoundingClientRect();
const panelWidth = 260;
const availHeight = window.innerHeight - rect.top - 16;
this._outlinePanel.style.width = `${panelWidth}px`;
this._outlinePanel.style.maxHeight = `${availHeight}px`;
this._outlinePanel.style.opacity = this._outlineOpacity;
if (rect.left > panelWidth + 8) {
this._outlinePanel.style.left = `${rect.left - panelWidth - 8}px`;
} else {
this._outlinePanel.style.left = `${rect.right + 8}px`;
}
this._outlinePanel.style.top = `${rect.top}px`;
}
_applyOutlineMargin() {
const activeLeaf = this.app.workspace.activeLeaf;
if (!activeLeaf) return;
const containerEl = activeLeaf.view.containerEl;
if (!containerEl) return;
let contentEl;
if (this.isReadingMode()) {
contentEl = containerEl.querySelector('.markdown-reading-view') ||
containerEl.querySelector('.markdown-preview-view');
} else {
contentEl = containerEl.querySelector('.cm-content') ||
containerEl.querySelector('.cm-editor') ||
containerEl.querySelector('.markdown-source-view');
}
if (contentEl) {
this._outlineOriginalMarginRight = contentEl.style.marginRight || '';
const panelWidth = 268;
contentEl.style.marginRight = `${panelWidth}px`;
}
}
_removeOutlineMargin() {
if (this._outlineOriginalMarginRight !== undefined) {
const activeLeaf = this.app.workspace.activeLeaf;
if (activeLeaf) {
const containerEl = activeLeaf.view.containerEl;
if (containerEl) {
let contentEl;
if (this.isReadingMode()) {
contentEl = containerEl.querySelector('.markdown-reading-view') ||
containerEl.querySelector('.markdown-preview-view');
} else {
contentEl = containerEl.querySelector('.cm-content') ||
containerEl.querySelector('.cm-editor') ||
containerEl.querySelector('.markdown-source-view');
}
if (contentEl) {
contentEl.style.marginRight = this._outlineOriginalMarginRight;
}
}
}
this._outlineOriginalMarginRight = undefined;
}
}
_highlightCurrentHeading() {
if (!this._outlinePanel) return;
const items = this._outlinePanel.querySelectorAll('.minimap-outline-item');
if (items.length === 0) return;
items.forEach(i => i.classList.remove('minimap-outline-active'));
const scrollEl = this._getScrollContainer();
if (!scrollEl) return;
const scrollTop = scrollEl.scrollTop;
const containerTop = scrollEl.getBoundingClientRect().top;
let currentHeading = null;
const isReading = this.isReadingMode();
if (isReading) {
const viewEl = this.app.workspace.activeLeaf?.view?.containerEl;
if (!viewEl) return;
const allHeadings = viewEl.querySelectorAll('.markdown-reading-view h1, .markdown-reading-view h2, .markdown-reading-view h3, .markdown-reading-view h4, .markdown-reading-view h5, .markdown-reading-view h6');
for (const h of allHeadings) {
const rect = h.getBoundingClientRect();
if (rect.top <= containerTop + 40) {
currentHeading = h.textContent.trim();
} else {
break;
}
}
} else {
const editor = this.app.workspace.activeLeaf?.view?.editor;
if (!editor) return;
const cursorLine = editor.getCursor().line;
const content = editor.getValue();
const lines = content.split('\n');
let lastHeadingLine = -1;
let lastHeadingText = null;
for (let i = 0; i <= cursorLine; i++) {
const m = lines[i].match(/^(#{1,6})\s+(.+?)(?:\s+#[\w\u4e00-\u9fa5\-\/]+)*\s*$/);
if (m) {
lastHeadingLine = i;
lastHeadingText = m[2].replace(/\s+#[\w\u4e00-\u9fa5\-\/]+\s*$/g, '').trim();
}
}
currentHeading = lastHeadingText;
}
if (currentHeading) {
for (const item of items) {
const itemText = item.dataset.heading;
if (itemText === currentHeading || itemText.includes(currentHeading) || currentHeading.includes(itemText)) {
item.classList.add('minimap-outline-active');
item.scrollIntoView({ block: 'nearest' });
break;
}
}
}
}
_getScrollContainer() {
if (this.isReadingMode()) {
return this.getReadingViewScrollEl();
}
const leaf = this.app.workspace.activeLeaf;
if (!leaf) return null;
return leaf.view.containerEl.querySelector('.cm-scroller') || leaf.view.containerEl;
}
setupListDrag() {
if (!this.matchList) return;
// Add resize handle
const resizeHandle = document.createElement('div');
resizeHandle.className = 'minimap-match-list-resize-handle';
this.matchList.appendChild(resizeHandle);
this.isResizingMatchList = false;
this.resizeStartX = 0;
this.resizeStartY = 0;
this.resizeStartWidth = 0;
this.resizeStartHeight = 0;
resizeHandle.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
this.isResizingMatchList = true;
this.isInteractingWithList = true;
this.resizeStartX = e.clientX;
this.resizeStartY = e.clientY;
this.resizeStartWidth = this.matchList.offsetWidth;
this.resizeStartHeight = this.matchList.offsetHeight;
});
document.addEventListener('mousemove', (e) => {
if (!this.isResizingMatchList || !this.matchList) return;
const dx = e.clientX - this.resizeStartX;
const dy = e.clientY - this.resizeStartY;
const newWidth = Math.max(200, Math.min(this.resizeStartWidth + dx, window.innerWidth - 20));
const newHeight = Math.max(150, Math.min(this.resizeStartHeight + dy, window.innerHeight - 20));
this.matchList.style.width = `${newWidth}px`;
this.matchList.style.maxWidth = `${newWidth}px`;
this.matchList.style.height = `${newHeight}px`;
// Update container max-height to fit within the list
const header = this.matchList.querySelector('.minimap-match-list-header');
const listContainer = this.matchList.querySelector('.minimap-match-list-container');
if (listContainer) {
const headerHeight = header ? header.offsetHeight : 36;
const recentSection = this.matchList.querySelector('.minimap-match-list-recent');
const recentHeight = recentSection ? recentSection.offsetHeight : 0;
const searchRow = this.matchList.querySelector('.minimap-floating-search-box')?.parentElement;
const searchRowHeight = searchRow ? searchRow.offsetHeight : 0;
const availableHeight = newHeight - headerHeight - recentHeight - 40;
listContainer.style.maxHeight = `${Math.max(50, availableHeight)}px`;
}
this.matchListSize.width = newWidth;
this.matchListSize.height = newHeight;
});
document.addEventListener('mouseup', (e) => {
if (this.isResizingMatchList) {
this.isResizingMatchList = false;
this.saveListData();
}
});
}
setupListWheel() {
if (!this.matchList) return;
this.matchList.addEventListener('wheel', this.boundListWheel, { passive: false });
if (this.previewPanel) {
this.previewPanel.addEventListener('wheel', this.boundListWheel, { passive: false });
}
}
handleListWheel(e) {
if (!e.ctrlKey) return;
e.preventDefault();
const delta = e.deltaY > 0 ? -0.05 : 0.05;
if (this.isPreviewOpen && this.previewPanel) {
this.previewOpacity = Math.max(0.1, Math.min(1.0, this.previewOpacity + delta));
this.previewPanel.style.opacity = this.previewOpacity;
} else {
this.listOpacity = Math.max(0.1, Math.min(1.0, this.listOpacity + delta));
this.matchList.style.opacity = this.listOpacity;
}
this.saveListData();
}
registerEditorField() {
const editor = this.getEditor();
if (!editor || !editor.cm) return;
if (!this.decorationField) {
this.decorationField = matchHighlightField;
editor.cm.dispatch({
effects: StateEffect.appendConfig.of([this.decorationField])
});
}
if (!this.jumpDecoField) {
this.jumpDecoField = jumpHighlightField;
editor.cm.dispatch({
effects: StateEffect.appendConfig.of([this.jumpDecoField])
});
}
this.matchHighlightPlugin = MatchHighlightPlugin;
}
setupPinnedMatchHover() {
const editor = this.getEditor();
if (!editor || !editor.cm) return;
const cm = editor.cm;
const scrollDOM = cm.scrollDOM;
if (!scrollDOM) return;
if (this._pinnedMatchHoverHandler) {
scrollDOM.removeEventListener('mouseover', this._pinnedMatchHoverHandler);
}
this._pinnedMatchHoverHandler = (e) => {
const target = e.target;
if (!target.classList || !target.classList.contains('minimap-editor-match')) return;
if (!target.classList.contains('pinned')) return;
if (this._isListVisible) return;
const selection = target.dataset.selection;
if (!selection) return;
const savedItem = this.savedMatchLists.find(item => item.selection === selection && item.pinned);
if (!savedItem) return;
this._searchGeneration++;
this._triggeredByIndicator = true;
const targetRect = target.getBoundingClientRect();
this.lastIndicatorCoords = {
screenX: targetRect.right + 10,
screenY: targetRect.top + targetRect.height / 2,
targetRect: targetRect
};
this._currentIndicatorSelection = selection;
this._currentIndicatorMatchCount = savedItem.matchCount;
this.showMatchList(selection, savedItem.matchCount);
};
scrollDOM.addEventListener('mouseover', this._pinnedMatchHoverHandler);
}
applyEditorPadding() {
const activeLeaf = this.app.workspace.activeLeaf;
if (!activeLeaf) return;
const containerEl = activeLeaf.view.containerEl;
if (!containerEl) return;
if (this.isReadingMode()) {
this.editorContentEl = containerEl.querySelector('.markdown-reading-view') ||
containerEl.querySelector('.markdown-preview-view');
} else {
this.editorContentEl = containerEl.querySelector('.cm-content') ||
containerEl.querySelector('.cm-editor') ||
containerEl.querySelector('.markdown-source-view');
}
if (this.editorContentEl) {
this.originalPaddingRight = this.editorContentEl.style.paddingRight || '';
const minimapWidth = this.minimapContainer ? this.minimapContainer.offsetWidth : 80;
const padding = this.settings.editorPadding || 10;
this.editorContentEl.style.paddingRight = `${minimapWidth + padding}px`;
}
}
removeEditorPadding() {
if (this.editorContentEl) {
this.editorContentEl.style.paddingRight = this.originalPaddingRight;
}
}
applyHideStatusBar() {
const statusBar = document.querySelector('.status-bar');
if (!statusBar) return;
if (this.settings.hideStatusBar) {
statusBar.classList.add('minimap-hide-statusbar');
} else {
statusBar.classList.remove('minimap-hide-statusbar');
}
}
setupScrollListener() {
if (this.scrollHandler) {
document.removeEventListener('scroll', this.scrollHandler, true);
}
const activeLeaf = this.app.workspace.activeLeaf;
if (activeLeaf) {
if (this.isReadingMode()) {
this.editorScrollEl = this.getReadingViewScrollEl() || activeLeaf.view.containerEl;
} else {
this.editorScrollEl = activeLeaf.view.containerEl.querySelector('.cm-scroller') ||
activeLeaf.view.containerEl.querySelector('.markdown-source-view') ||
activeLeaf.view.containerEl;
}
}
this.scrollHandler = (e) => {
if (this.isDragging) return;
this.updateViewport();
this.updatePinnedIndicatorsPosition();
};
document.addEventListener('scroll', this.scrollHandler, true);
if (this.editorScrollEl) {
this.editorScrollEl.addEventListener('scroll', this.scrollHandler);
}
}
// Get current visible line range using CodeMirror API
getVisibleLineRange() {
if (this.isReadingMode()) {
const scrollEl = this.getReadingViewScrollEl();
if (!scrollEl) return null;
const totalLines = this.getTotalLines();
const scrollTop = scrollEl.scrollTop;
const clientHeight = scrollEl.clientHeight;
const scrollHeight = scrollEl.scrollHeight;
const scrollRatio = scrollHeight > clientHeight ? scrollTop / (scrollHeight - clientHeight) : 0;
const viewportRatio = scrollHeight > 0 ? clientHeight / scrollHeight : 1;
const startLine = Math.floor(scrollRatio * totalLines);
const visibleLines = Math.max(1, Math.floor(viewportRatio * totalLines));
const endLine = Math.min(totalLines - 1, startLine + visibleLines - 1);
return {
startLine: startLine,
endLine: endLine,
visibleLines: visibleLines,
totalLines: totalLines,
scrollTop: scrollTop,
scrollHeight: scrollHeight,
clientHeight: clientHeight
};
}
const editor = this.getEditor();
if (!editor || !editor.cm) return null;
const cm = editor.cm;
const scrollDOM = cm.scrollDOM;
if (!scrollDOM) return null;
const scrollTop = scrollDOM.scrollTop;
const clientHeight = scrollDOM.clientHeight;
// Get total line count
const totalLines = cm.state.doc.lines;
// Get the line at the top of the viewport
const topLine = cm.lineBlockAtHeight(scrollTop);
const startLine = topLine ? topLine.from : 0;
// Get the line at the bottom of the viewport
const bottomLine = cm.lineBlockAtHeight(scrollTop + clientHeight);
const endLine = bottomLine ? bottomLine.from : cm.state.doc.length;
// Convert positions to line numbers (0-indexed)
const startLineNumber = cm.state.doc.lineAt(startLine).number - 1;
const endLineNumber = cm.state.doc.lineAt(endLine).number - 1;
// Calculate how many lines are visible
const visibleLines = endLineNumber - startLineNumber + 1;
return {
startLine: startLineNumber,
endLine: endLineNumber,
visibleLines: visibleLines,
totalLines: totalLines,
scrollTop: scrollTop,
scrollHeight: scrollDOM.scrollHeight,
clientHeight: clientHeight
};
}
hexToRgba(color, alpha) {
if (color.startsWith('rgba')) {
return color.replace(/rgba\(([^,]+),\s*([^,]+),\s*([^,]+),\s*[^)]+\)/, `rgba($1, $2, $3, ${alpha})`);
}
const r = parseInt(color.slice(1, 3), 16);
const g = parseInt(color.slice(3, 5), 16);
const b = parseInt(color.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
_highlightKeywordsInElement(el, keywords, colors) {
if (!el || !keywords || keywords.length === 0) return;
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null, false);
const textNodes = [];
while (walker.nextNode()) {
textNodes.push(walker.currentNode);
}
for (const textNode of textNodes) {
const text = textNode.textContent;
if (!text) continue;
let hasAnyMatch = false;
for (const kw of keywords) {
if (text.toLowerCase().includes(kw.toLowerCase())) {
hasAnyMatch = true;
break;
}
}
if (!hasAnyMatch) continue;
const fragment = document.createDocumentFragment();
let remaining = text;
while (remaining.length > 0) {
let earliestIdx = remaining.length;
let earliestKwIdx = -1;
for (let ki = 0; ki < keywords.length; ki++) {
const idx = remaining.toLowerCase().indexOf(keywords[ki].toLowerCase());
if (idx !== -1 && idx < earliestIdx) {
earliestIdx = idx;
earliestKwIdx = ki;
}
}
if (earliestKwIdx === -1) {
fragment.appendChild(document.createTextNode(remaining));
break;
}
if (earliestIdx > 0) {
fragment.appendChild(document.createTextNode(remaining.slice(0, earliestIdx)));
}
const kw = keywords[earliestKwIdx];
const color = colors[earliestKwIdx];
const mark = document.createElement('mark');
mark.textContent = remaining.slice(earliestIdx, earliestIdx + kw.length);
mark.style.cssText = `background:${color}33;color:${color};padding:0 2px;border-radius:3px;font-weight:600;`;
fragment.appendChild(mark);
remaining = remaining.slice(earliestIdx + kw.length);
}
textNode.parentNode.replaceChild(fragment, textNode);
}
}
// Get total line count
getTotalLines() {
if (this.isReadingMode()) {
const content = this.minimapContent.textContent || '';
return content.split('\n').length;
}
const editor = this.getEditor();
if (!editor) return 0;
const content = editor.getValue();
return content.split('\n').length;
}
// Get current scroll position as line number (0-indexed)
getCurrentTopLine() {
if (this.isReadingMode()) {
const scrollEl = this.getReadingViewScrollEl();
if (!scrollEl) return 0;
const totalLines = this.getTotalLines();
if (totalLines <= 0) return 0;
const scrollRatio = scrollEl.scrollTop / Math.max(1, scrollEl.scrollHeight - scrollEl.clientHeight);
return Math.floor(scrollRatio * totalLines);
}
const editor = this.getEditor();
if (!editor || !editor.cm) return 0;
const cm = editor.cm;
const scrollDOM = cm.scrollDOM;
if (!scrollDOM) return 0;
const scrollTop = scrollDOM.scrollTop;
const topBlock = cm.lineBlockAtHeight(scrollTop);
if (topBlock) {
return cm.state.doc.lineAt(topBlock.from).number - 1;
}
return 0;
}
// Get how many lines are visible in the viewport
getVisibleLineCount() {
if (this.isReadingMode()) {
const scrollEl = this.getReadingViewScrollEl();
if (!scrollEl) return 1;
const totalLines = this.getTotalLines();
if (totalLines <= 0) return 1;
const viewportRatio = scrollEl.clientHeight / Math.max(1, scrollEl.scrollHeight);
return Math.max(1, Math.floor(viewportRatio * totalLines));
}
const editor = this.getEditor();
if (!editor || !editor.cm) return 1;
const cm = editor.cm;
const scrollDOM = cm.scrollDOM;
if (!scrollDOM) return 1;
const scrollTop = scrollDOM.scrollTop;
const clientHeight = scrollDOM.clientHeight;
const topBlock = cm.lineBlockAtHeight(scrollTop);
const bottomBlock = cm.lineBlockAtHeight(scrollTop + clientHeight);
if (topBlock && bottomBlock) {
const startLine = cm.state.doc.lineAt(topBlock.from).number;
const endLine = cm.state.doc.lineAt(bottomBlock.from).number;
return Math.max(1, endLine - startLine + 1);
}
return 1;
}
setupSliderEvents() {
if (!this.slider || !this.minimapContainer) return;
this.slider.addEventListener('mousedown', (e) => {
e.stopPropagation();
e.preventDefault();
this.isDragging = true;
this.slider.classList.add('dragging');
this.dragStartY = e.clientY;
this.dragStartLine = this.getCurrentTopLine();
});
this.minimapContainer.addEventListener('mousedown', (e) => {
if (e.target === this.slider) return;
if (e.target.classList.contains('minimap-highlight')) return;
if (e.button !== 0) return;
this.jumpToPosition(e);
});
}
handleMouseMove(e) {
if (!this.isDragging) return;
e.preventDefault();
if (!this.minimapContainer) return;
const containerHeight = this.minimapContainer.clientHeight;
const deltaY = e.clientY - this.dragStartY;
const totalLines = this.getTotalLines();
if (totalLines <= 0) return;
const lineHeightInMinimap = 2;
const naturalContentHeight = totalLines * lineHeightInMinimap;
const contentHeight = Math.min(containerHeight, naturalContentHeight);
const lineDelta = (deltaY / contentHeight) * totalLines;
const newLine = this.dragStartLine + lineDelta;
const clampedLine = Math.max(0, Math.min(newLine, totalLines - 1));
this.scrollToLine(clampedLine);
}
scrollToLine(lineNumber) {
if (this.isReadingMode()) {
const scrollEl = this.getReadingViewScrollEl();
if (!scrollEl) return;
const file = this.app.workspace.activeLeaf?.view?.file;
if (!file) return;
this.app.vault.read(file).then(content => {
const lines = content.split('\n');
if (lineNumber < 0 || lineNumber >= lines.length) return;
// Calculate approximate scroll position based on line ratio
const ratio = lineNumber / Math.max(1, lines.length - 1);
const scrollHeight = scrollEl.scrollHeight - scrollEl.clientHeight;
scrollEl.scrollTop = ratio * scrollHeight;
}).catch(() => {});
if (this.currentSelection) {
setTimeout(() => this.highlightMatches(), 10);
}
return;
}
const editor = this.getEditor();
if (!editor || !editor.cm) return;
const cm = editor.cm;
const doc = cm.state.doc;
if (lineNumber < 0 || lineNumber >= doc.lines) return;
const line = doc.line(lineNumber + 1);
// Scroll the DOM directly without changing cursor
if (cm.scrollDOM) {
const lineBlock = cm.lineBlockAt(line.from);
if (lineBlock) {
cm.scrollDOM.scrollTop = lineBlock.top;
}
}
// Re-apply highlights after scroll
if (this.currentSelection) {
setTimeout(() => this.highlightMatches(), 10);
}
}
handleMouseUp(e) {
if (this.isDragging) {
this.isDragging = false;
if (this.slider) {
this.slider.classList.remove('dragging');
}
if (this.currentSelection) {
setTimeout(() => this.highlightMatches(), 10);
}
} else if (!this.isJumping && !this.isInteractingWithList) {
// Don't trigger handleSelection if mouseup is inside the match list (text selection)
const isInsideMatchList = this.matchList && this.matchList.contains(e.target);
const isInsideKeywordBtn = e.target.closest('.swift-match-keyword-btn-wrapper');
if (!isInsideMatchList && !isInsideKeywordBtn) {
this.handleSelection();
}
this.isJumping = false;
}
this.isInteractingWithList = false;
}
handleWheel(e) {
e.preventDefault();
// Alt + wheel: adjust opacity
if (e.altKey) {
const delta = e.deltaY > 0 ? -0.05 : 0.05;
this.opacity = Math.max(0.1, Math.min(1, this.opacity + delta));
if (this.minimapContainer) {
this.minimapContainer.style.opacity = this.opacity;
}
} else {
// Normal wheel: scroll document page by page
if (this.isReadingMode()) {
const scrollEl = this.getReadingViewScrollEl();
if (!scrollEl) return;
const clientHeight = scrollEl.clientHeight;
const scrollAmount = clientHeight * 0.9;
const direction = e.deltaY > 0 ? 1 : -1;
scrollEl.scrollTop += direction * scrollAmount;
} else {
const editor = this.getEditor();
if (!editor || !editor.cm) return;
const cm = editor.cm;
if (!cm.scrollDOM) return;
const clientHeight = cm.scrollDOM.clientHeight;
const scrollAmount = clientHeight * 0.9; // Scroll 90% of page height
const direction = e.deltaY > 0 ? 1 : -1;
cm.scrollDOM.scrollTop += direction * scrollAmount;
}
}
}
jumpToPosition(e) {
const totalLines = this.getTotalLines();
if (totalLines <= 0) return;
const containerHeight = this.minimapContainer.clientHeight;
const containerRect = this.minimapContainer.getBoundingClientRect();
const clickY = e.clientY - containerRect.top;
const lineHeightInMinimap = 2;
const naturalContentHeight = totalLines * lineHeightInMinimap;
const contentHeight = Math.min(containerHeight, naturalContentHeight);
const ratio = clickY / contentHeight;
const targetLine = Math.floor(ratio * totalLines);
const clampedLine = Math.max(0, Math.min(targetLine, totalLines - 1));
this.isJumping = true;
if (this.isReadingMode()) {
this.scrollToLine(clampedLine);
} else {
const editor = this.getEditor();
if (editor && editor.cm) {
try {
this.jumpToMatch({ line: clampedLine, pos: 0 });
} catch (err) {
this.scrollToLine(clampedLine);
}
} else {
this.scrollToLine(clampedLine);
}
}
}
jumpToMatch(targetMatch) {
if (this.isReadingMode()) {
const scrollEl = this.getReadingViewScrollEl();
if (!scrollEl) return;
const file = this.app.workspace.activeLeaf?.view?.file;
if (!file) return;
this.app.vault.read(file).then(content => {
const lines = content.split('\n');
const lineNumber = targetMatch.line;
if (lineNumber < 0 || lineNumber >= lines.length) return;
const ratio = lineNumber / Math.max(1, lines.length - 1);
const scrollHeight = scrollEl.scrollHeight - scrollEl.clientHeight;
scrollEl.scrollTop = ratio * scrollHeight;
}).catch(() => {});
this.isJumping = true;
setTimeout(() => {
this.isJumping = false;
}, 150);
return;
}
const editor = this.getEditor();
if (!editor) return;
const cm = editor.cm;
if (!cm) return;
this.isJumping = true;
const line = cm.state.doc.line(targetMatch.line + 1);
const foundPos = line.from + targetMatch.pos;
const originalSelection = this.currentSelection;
const originalCursor = this.currentCursor;
cm.dispatch({
selection: { anchor: foundPos },
scrollIntoView: { range: { from: foundPos, to: foundPos } }
});
setTimeout(() => {
if (!this.isJumping) return;
this.currentSelection = originalSelection;
this.currentCursor = originalCursor;
if (this.currentSelection) {
this.highlightMatches();
}
this.isJumping = false;
}, 150);
}
getEditor() {
return this.app.workspace.activeLeaf?.view?.editor || null;
}
showPinIcon() {
if (!this.settings.pinIconEnabled) return;
if (!this.currentSelection) return;
if (this._pinIcon) return;
// Check if current selection is already pinned
const exactPinned = this.savedMatchLists.find(m => m.selection === this.currentSelection && m.pinned);
// Check if current selection contains any pinned texts
const containedPinned = this.savedMatchLists.filter(m => m.pinned && this.currentSelection.includes(m.selection));
// Save current pin icon position before hiding (in case it was dragged)
if (this._pinIcon && this.settings.pinIconMode === 'fixed') {
this.settings.pinIconFixedLeft = parseFloat(this._pinIcon.style.left);
this.settings.pinIconFixedTop = parseFloat(this._pinIcon.style.top);
}
this.hidePinIcon();
const icon = document.createElement('div');
icon.className = 'minimap-pin-icon';
icon.style.position = 'fixed';
icon.style.zIndex = '9999';
icon.style.cursor = 'pointer';
icon.style.width = `${this.settings.pinIconSize}px`;
icon.style.height = `${this.settings.pinIconSize}px`;
icon.style.borderRadius = '50%';
icon.style.fontSize = `${Math.round(this.settings.pinIconSize * 0.55)}px`;
icon.style.fontWeight = '700';
icon.style.display = 'flex';
icon.style.alignItems = 'center';
icon.style.justifyContent = 'center';
icon.style.opacity = this.settings.pinIconOpacity;
icon.style.transition = 'opacity 0.15s, transform 0.15s';
icon.style.userSelect = 'none';
icon.style.lineHeight = '1';
icon.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)';
if (exactPinned) {
// Current selection is pinned - show unpin button (gray circle, gray x)
icon.textContent = 'x';
icon.title = t('unpin');
icon.style.backgroundColor = '#888';
icon.style.color = '#fff';
} else if (containedPinned.length > 0) {
// Current selection contains pinned texts - show clear button (gray circle, gray x)
icon.textContent = 'x';
icon.title = t('clearPinned', containedPinned.length);
icon.style.backgroundColor = '#888';
icon.style.color = '#fff';
} else {
// Not pinned - show pin button (gray circle, gray p)
icon.textContent = 'p';
icon.title = t('pinMatch');
icon.style.backgroundColor = '#888';
icon.style.color = '#fff';
}
if (this.settings.pinIconMode === 'follow') {
const ox = this.settings.pinIconFollowOffsetX ?? 15;
const oy = this.settings.pinIconFollowOffsetY ?? -10;
icon.style.left = `${this._lastMouseX + ox}px`;
icon.style.top = `${this._lastMouseY + oy}px`;
} else {
// Fixed position: use saved position or default to right side of editor
if (this.settings.pinIconFixedLeft != null && this.settings.pinIconFixedTop != null) {
icon.style.left = `${this.settings.pinIconFixedLeft}px`;
icon.style.top = `${this.settings.pinIconFixedTop}px`;
} else {
const leaf = this.app.workspace.activeLeaf;
if (leaf) {
const rect = leaf.view.containerEl.getBoundingClientRect();
icon.style.left = `${rect.right - 40}px`;
icon.style.top = `${rect.top + 10}px`;
} else {
icon.style.left = `${window.innerWidth - 50}px`;
icon.style.top = '100px';
}
}
}
icon.addEventListener('mouseenter', () => {
icon.style.transform = 'scale(1.2)';
icon.style.opacity = '1';
});
icon.addEventListener('mouseleave', () => {
icon.style.transform = 'scale(1)';
icon.style.opacity = this.settings.pinIconOpacity;
});
// Click to pin
let clickDisabled = false;
icon.addEventListener('mousedown', (e) => {
e.stopPropagation();
this.isInteractingWithList = true;
});
icon.addEventListener('click', (e) => {
if (clickDisabled) {
clickDisabled = false;
return;
}
e.preventDefault();
e.stopPropagation();
if (exactPinned) {
// Unpin the exact match
this.removePinnedSelection(this.currentSelection);
} else if (containedPinned.length > 0) {
// Remove all contained pinned items
for (const item of containedPinned) {
const idx = this.savedMatchLists.findIndex(m => m.selection === item.selection && m.pinned);
if (idx >= 0) {
this.savedMatchLists.splice(idx, 1);
}
}
this.saveListData();
this.clearHighlights();
this.showPinnedDecorations();
if (this.currentSelection) {
this.highlightMatches();
}
} else {
// Pin current selection
this.pinCurrentSelection();
}
this.hidePinIcon();
});
// Drag support for fixed mode
if (this.settings.pinIconMode === 'fixed') {
let isDraggingIcon = false;
let hasDragged = false;
let dragStartX = 0;
let dragStartY = 0;
let dragOffsetX = 0;
let dragOffsetY = 0;
icon.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
isDraggingIcon = true;
hasDragged = false;
dragStartX = e.clientX;
dragStartY = e.clientY;
dragOffsetX = e.clientX - icon.getBoundingClientRect().left;
dragOffsetY = e.clientY - icon.getBoundingClientRect().top;
icon.style.transition = 'none';
const onDragMove = (e) => {
if (!isDraggingIcon) return;
const dx = e.clientX - dragStartX;
const dy = e.clientY - dragStartY;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) {
if (!hasDragged) {
hasDragged = true;
icon.style.cursor = 'grabbing';
}
const newLeft = e.clientX - dragOffsetX;
const newTop = e.clientY - dragOffsetY;
icon.style.left = `${newLeft}px`;
icon.style.top = `${newTop}px`;
}
};
const onDragEnd = (e) => {
if (!isDraggingIcon) return;
isDraggingIcon = false;
icon.style.cursor = 'pointer';
icon.style.transition = 'opacity 0.15s, transform 0.15s';
if (hasDragged) {
// Was a drag - save position and prevent click
clickDisabled = true;
this.settings.pinIconFixedLeft = parseFloat(icon.style.left);
this.settings.pinIconFixedTop = parseFloat(icon.style.top);
this.saveSettings();
}
document.removeEventListener('mousemove', onDragMove);
document.removeEventListener('mouseup', onDragEnd);
};
document.addEventListener('mousemove', onDragMove);
document.addEventListener('mouseup', onDragEnd);
});
}
document.body.appendChild(icon);
this._pinIcon = icon;
// Follow mouse mode: show icon at current mouse position, then stay fixed
if (this.settings.pinIconMode === 'follow') {
const ox = this.settings.pinIconFollowOffsetX ?? 15;
const oy = this.settings.pinIconFollowOffsetY ?? -10;
icon.style.left = `${this._lastMouseX + ox}px`;
icon.style.top = `${this._lastMouseY + oy}px`;
}
}
hidePinIcon() {
if (this._pinIcon) {
this._pinIcon.remove();
this._pinIcon = null;
}
}
pinCurrentSelection() {
if (!this.currentSelection) return;
const multiKeywords = this.parseMultiKeywords(this.currentSelection);
if (multiKeywords) {
const keywordColors = this.getMultiKeywordColors(multiKeywords.length);
const currentFile = this.app.workspace.activeLeaf?.view?.file;
const currentFilePath = currentFile ? currentFile.path : '';
const currentFileName = currentFile ? currentFile.basename : '';
for (let ki = 0; ki < multiKeywords.length; ki++) {
const kw = multiKeywords[ki];
const color = keywordColors[ki];
const existingIndex = this.savedMatchLists.findIndex(m => m.selection === kw);
if (existingIndex >= 0) {
this.savedMatchLists[existingIndex].pinned = true;
this.savedMatchLists[existingIndex].borderColor = color;
this.savedMatchLists[existingIndex].counterBgColor = color;
this.savedMatchLists[existingIndex].counterColor = '#aba6a6';
this.savedMatchLists[existingIndex]._multiKeywordParent = this.currentSelection;
if (!this.savedMatchLists[existingIndex].filePath) {
this.savedMatchLists[existingIndex].filePath = currentFilePath;
this.savedMatchLists[existingIndex].fileName = currentFileName;
}
} else {
this.savedMatchLists.unshift({
selection: kw,
pinned: true,
colorIndex: 0,
borderColor: color,
counterBgColor: color,
counterColor: '#aba6a6',
matchCount: 0,
filePath: currentFilePath,
fileName: currentFileName,
_multiKeywordParent: this.currentSelection
});
}
}
this.saveListData();
this.clearHighlights();
this.showPinnedDecorations();
return;
}
// Calculate next color index
const pinnedItems = this.savedMatchLists.filter(m => m.pinned);
const nextColorIndex = pinnedItems.length % this.settings.pinColorSchemes.length;
const colorScheme = this.settings.pinColorSchemes[nextColorIndex];
const currentFile = this.app.workspace.activeLeaf?.view?.file;
const currentFilePath = currentFile ? currentFile.path : '';
const existingIndex = this.savedMatchLists.findIndex(m => m.selection === this.currentSelection);
if (existingIndex >= 0) {
this.savedMatchLists[existingIndex].pinned = true;
this.savedMatchLists[existingIndex].colorIndex = nextColorIndex;
this.savedMatchLists[existingIndex].borderColor = colorScheme.borderColor;
this.savedMatchLists[existingIndex].counterBgColor = colorScheme.counterBgColor;
this.savedMatchLists[existingIndex].counterColor = colorScheme.counterColor;
if (!this.savedMatchLists[existingIndex].filePath) {
this.savedMatchLists[existingIndex].filePath = currentFilePath;
this.savedMatchLists[existingIndex].fileName = currentFile ? currentFile.basename : '';
}
} else {
this.savedMatchLists.unshift({
selection: this.currentSelection,
pinned: true,
colorIndex: nextColorIndex,
borderColor: colorScheme.borderColor,
counterBgColor: colorScheme.counterBgColor,
counterColor: colorScheme.counterColor,
matchCount: 0,
filePath: currentFilePath,
fileName: currentFile ? currentFile.basename : ''
});
}
this.saveListData();
// Re-apply highlights with pinned colors
this.clearHighlights();
this.showPinnedDecorations();
// Also re-highlight current selection on top
if (this.currentSelection) {
this.highlightMatches();
}
}
unpinSelection(selection) {
const item = this.savedMatchLists.find(m => m.selection === selection && m.pinned);
if (item) {
item.pinned = false;
delete item.colorIndex;
delete item.borderColor;
delete item.counterBgColor;
delete item.counterColor;
if (item._multiKeywordParent) {
const parent = item._multiKeywordParent;
this.savedMatchLists.forEach(m => {
if (m._multiKeywordParent === parent && m.pinned) {
m.pinned = false;
delete m.colorIndex;
delete m.borderColor;
delete m.counterBgColor;
delete m.counterColor;
}
});
}
this.saveListData();
}
this.clearHighlights();
this.showPinnedDecorations();
if (this.currentSelection) {
this.highlightMatches();
}
}
removePinnedSelection(selection) {
const item = this.savedMatchLists.find(m => m.selection === selection);
if (item && item._multiKeywordParent) {
const parent = item._multiKeywordParent;
this.savedMatchLists = this.savedMatchLists.filter(m => m._multiKeywordParent !== parent);
this.saveListData();
} else {
const index = this.savedMatchLists.findIndex(m => m.selection === selection);
if (index >= 0) {
this.savedMatchLists.splice(index, 1);
this.saveListData();
}
}
this.clearHighlights();
this.showPinnedDecorations();
if (this.currentSelection) {
this.highlightMatches();
}
}
isReadingMode() {
const leaf = this.app.workspace.activeLeaf;
if (!leaf) return false;
const viewState = leaf.getViewState();
return viewState && viewState.state && viewState.state.mode === 'preview';
}
getReadingViewScrollEl() {
const leaf = this.app.workspace.activeLeaf;
if (!leaf) return null;
const containerEl = leaf.view.containerEl;
if (!containerEl) return null;
// Try to find the actual scrolling container by checking which element has scrollable content
const candidates = [
containerEl.querySelector('.markdown-reading-view'),
containerEl.querySelector('.markdown-preview-view'),
containerEl.querySelector('.workspace-leaf-content'),
containerEl.querySelector('.view-content'),
];
for (const el of candidates) {
if (el && el.scrollHeight > el.clientHeight + 1) {
return el;
}
}
// Fallback: find any scrollable ancestor of the reading view
const readingView = containerEl.querySelector('.markdown-reading-view') ||
containerEl.querySelector('.markdown-preview-view');
if (readingView) {
let parent = readingView.parentElement;
while (parent && parent !== containerEl) {
if (parent.scrollHeight > parent.clientHeight + 1) {
return parent;
}
parent = parent.parentElement;
}
}
return readingView || containerEl;
}
getReadingViewContentEl() {
const leaf = this.app.workspace.activeLeaf;
if (!leaf) return null;
const containerEl = leaf.view.containerEl;
if (!containerEl) return null;
return containerEl.querySelector('.markdown-reading-view') ||
containerEl.querySelector('.markdown-preview-view');
}
async getFileContent() {
const file = this.app.workspace.activeLeaf?.view?.file;
if (!file) return '';
try {
return await this.app.vault.read(file);
} catch (e) {
return '';
}
}
registerEvents() {
// Track mouse position for pin icon follow mode
document.addEventListener('mousemove', (e) => {
this._lastMouseX = e.clientX;
this._lastMouseY = e.clientY;
});
this.registerEvent(
this.app.workspace.on('editor-change', () => {
this.scheduleUpdate();
})
);
this.registerEvent(
this.app.workspace.on('active-leaf-change', (leaf) => {
if (this.isPreviewOpen && this.previewSourceLeaf && leaf !== this.previewSourceLeaf) {
this.closePreview();
return;
}
// Clear count when document changes if clearCountOnClose is enabled
if (this.settings.clearCountOnClose) {
this.savedMatchLists = [];
this.saveListData();
}
this.hidePinIcon();
this.clearHighlights();
this.hideAllMatchListIndicators();
this.setupMinimap();
this.updateMinimapContent();
this.handleSelection();
// Setup SwiftMatch export navigation
this._setupSwiftMatchExportNav();
})
);
// Listen for mode changes (source <-> reading)
this.registerEvent(
this.app.workspace.on('layout-change', () => {
if (this._isApplyingReadingHighlights) return;
this.clearHighlights();
this.hideAllMatchListIndicators();
this.setupMinimap();
this.updateMinimapContent();
// Re-apply highlights after layout change if there's a current selection
if (this.currentSelection) {
setTimeout(() => this.handleSelection(), 150);
}
})
);
// Handle text selection in reading mode via mouseup
this._readingModeMouseUpHandler = (e) => {
if (this.isReadingMode()) {
// Don't trigger if mouseup is inside the match list (text selection)
const isInsideMatchList = this.matchList && this.matchList.contains(e.target);
const isInsideKeywordBtn = e.target.closest('.swift-match-keyword-btn-wrapper');
if (!isInsideMatchList && !isInsideKeywordBtn) {
setTimeout(() => this.handleSelection(), 100);
}
}
};
document.addEventListener('mouseup', this._readingModeMouseUpHandler);
// Handle keyup for keyboard-based selection in reading mode
this._readingModeKeyUpHandler = (e) => {
if (this.isReadingMode() && e.shiftKey) {
setTimeout(() => this.handleSelection(), 100);
}
};
document.addEventListener('keyup', this._readingModeKeyUpHandler);
this.registerEvent(
this.app.vault.on('modify', (file) => {
})
);
this.registerEvent(
this.app.vault.on('delete', (file) => {
})
);
this.registerEvent(
this.app.vault.on('rename', (file, oldPath) => {
})
);
window.addEventListener('resize', () => {
this.updateMinimapContent();
this.clampFloatingTogglePosition();
});
this.registerInterval(
window.setInterval(() => {
this.updateViewport();
}, 50)
);
}
scheduleUpdate() {
if (this.updateTimer) clearTimeout(this.updateTimer);
this.updateTimer = setTimeout(() => {
this.updateMinimapContent();
this.handleSelection();
}, 150);
}
handleSelection() {
if (this.isJumping) return;
if (this.isPreviewOpen) return;
if (this.isReadingMode()) {
const sel = window.getSelection();
const selectionText = sel ? sel.toString().trim() : '';
if (selectionText.length > 0) {
if (this._chipSwitching && selectionText === this.currentSelection) return;
this._chipSwitching = false;
clearTimeout(this._chipSwitchTimer);
if (this.currentSelection !== selectionText) this._listUserDismissed = false;
this.currentSelection = selectionText;
this.currentCursor = null;
this.highlightMatches();
this.showPinIcon();
} else if (this._isApplyingReadingHighlights) {
return;
} else {
if (this._chipSwitching) return;
this.currentSelection = '';
this.hidePinIcon();
this.clearHighlights();
this.hideNonPinnedIndicators();
this.showPinnedDecorations();
}
} else {
const editor = this.getEditor();
if (!editor) return;
const selection = editor.getSelection();
if (selection && selection.trim().length > 0) {
if (this._chipSwitching && selection.trim() === this.currentSelection) return;
this._chipSwitching = false;
clearTimeout(this._chipSwitchTimer);
if (this.currentSelection !== selection.trim()) this._listUserDismissed = false;
this.currentSelection = selection.trim();
this.currentCursor = editor.getCursor();
this.highlightMatches();
this.showPinIcon();
} else {
if (this._chipSwitching) return;
this.currentSelection = '';
this.hidePinIcon();
this.clearHighlights();
this.hideNonPinnedIndicators();
this.showPinnedDecorations();
}
}
}
highlightMatches() {
if (!this.minimapContainer || !this.currentSelection || this.isDragging) return;
this.clearHighlights();
this.hideAllMatchListIndicators();
const selection = this.currentSelection;
if (selection.length < 1) return;
if (selection.length === 1 && !/[a-zA-Z0-9\u4e00-\u9fa5]/.test(selection)) return;
this.showAllPinnedIndicators();
const isReading = this.isReadingMode();
let content;
if (isReading) {
content = this.minimapContent.textContent || '';
} else {
const editor = this.getEditor();
if (!editor || !editor.cm) return;
content = editor.getValue();
}
const containerHeight = this.minimapContainer.clientHeight;
const lineCount = content.split('\n').length;
const currentLine = (!isReading && this.currentCursor) ? this.currentCursor.line : -1;
const lineHeightInMinimap = 2;
const naturalContentHeight = lineCount * lineHeightInMinimap;
const contentHeight = Math.min(containerHeight, naturalContentHeight);
const lines = content.split('\n');
const matchLines = [];
const searchLower = selection.toLowerCase();
for (let i = 0; i < lines.length; i++) {
const lineLower = lines[i].toLowerCase();
let pos = 0;
while ((pos = lineLower.indexOf(searchLower, pos)) !== -1) {
if (!matchLines.some(m => m.line === i && m.pos === pos)) {
matchLines.push({ line: i, pos: pos, index: matchLines.length + 1 });
}
pos += 1;
}
}
const totalMatches = matchLines.length;
const fragment = document.createDocumentFragment();
const currentCursor = (!isReading && this.currentCursor) ? this.currentCursor.ch : 0;
const currentColorScheme = this.getCurrentSelectionColorScheme();
for (let idx = 0; idx < matchLines.length; idx++) {
const match = matchLines[idx];
const i = match.line;
const highlight = document.createElement('div');
highlight.dataset.matchIndex = match.index;
const highlightHeight = Math.max(4, contentHeight / Math.max(1, lineCount));
const topPercentage = i / Math.max(1, lineCount - 1);
const top = topPercentage * contentHeight;
if (i === currentLine) {
highlight.className = 'minimap-highlight minimap-highlight-current';
highlight.style.backgroundColor = currentColorScheme.borderColor;
} else {
highlight.className = 'minimap-highlight';
highlight.style.backgroundColor = currentColorScheme.borderColor;
}
highlight.style.left = '0';
highlight.style.top = `${top}px`;
highlight.style.width = '100%';
highlight.style.height = `${highlightHeight}px`;
highlight.dataset.matchTotal = totalMatches;
highlight.dataset.line = i;
highlight.dataset.pos = match.pos;
const targetMatch = match;
const tooltip = this.tooltip;
highlight.addEventListener('mousedown', (e) => {
e.stopPropagation();
e.preventDefault();
this.jumpToMatch(targetMatch);
});
highlight.addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
});
highlight.addEventListener('mouseenter', (e) => {
if (!tooltip) return;
tooltip.textContent = `${highlight.dataset.matchIndex}/${highlight.dataset.matchTotal}`;
tooltip.style.display = 'block';
});
highlight.addEventListener('mousemove', (e) => {
if (!tooltip) return;
let left = e.clientX + 15;
let top = e.clientY - 30;
if (left + 80 > window.innerWidth) left = window.innerWidth - 90;
if (top < 10) top = 10;
tooltip.style.left = `${left}px`;
tooltip.style.top = `${top}px`;
});
highlight.addEventListener('mouseleave', () => {
if (!tooltip) return;
tooltip.style.display = 'none';
});
fragment.appendChild(highlight);
this.highlights.push(highlight);
}
this.minimapContainer.appendChild(fragment);
if (isReading) {
this.showPinnedDecorations();
const isCurrentPinned = this.currentSelection && this.savedMatchLists.some(m => m.selection === this.currentSelection && m.pinned);
if (!isCurrentPinned) {
this.addReadingViewHighlights(selection, totalMatches, this.getCurrentSelectionColorScheme());
}
} else {
const editor = this.getEditor();
if (editor && editor.cm) {
this.addEditorDecorationsWithPinned(editor.cm, matchLines, totalMatches);
}
}
if (totalMatches > 0) {
this.saveCurrentMatchList(selection, totalMatches);
} else {
this.hideAllMatchListIndicators();
}
this.showMatchList(selection, matchLines.length);
}
addReadingViewHighlights(searchText, totalMatches, colorScheme = null) {
const readingEl = this.getReadingViewContentEl();
if (!readingEl) return;
const savedItem = this.savedMatchLists.find(m => m.selection === searchText);
const hideCounter = savedItem && savedItem.countHidden;
this._isApplyingReadingHighlights = true;
// Disconnect previous observer
if (this._readingViewObserver) {
this._readingViewObserver.disconnect();
this._readingViewObserver = null;
}
if (this._readingHighlightRetryTimer) {
clearTimeout(this._readingHighlightRetryTimer);
this._readingHighlightRetryTimer = null;
}
const borderColor = colorScheme ? colorScheme.borderColor : this.getCurrentSelectionColorScheme().borderColor;
const counterColor = colorScheme ? colorScheme.counterColor : this.getCurrentSelectionColorScheme().counterColor;
const counterBgColor = colorScheme ? colorScheme.counterBgColor : this.getCurrentSelectionColorScheme().counterBgColor;
const counterSize = this.settings.counterSize;
const counterPadding = '0px 2px';
const counterTopOffset = `${this.settings.counterTopOffset}px`;
readingEl.style.setProperty('--minimap-match-color', borderColor);
readingEl.style.setProperty('--minimap-counter-opacity', 1);
readingEl.style.setProperty('--minimap-counter-color', counterColor);
const preset = this.settings.counterStylePreset || 'glass';
readingEl.style.setProperty('--minimap-counter-bgcolor', preset === 'outlined' ? counterBgColor : this.hexToRgba(counterBgColor, 1));
readingEl.style.setProperty('--minimap-counter-size', `${counterSize}px`);
readingEl.style.setProperty('--minimap-counter-padding', counterPadding);
readingEl.style.setProperty('--minimap-counter-top-offset', counterTopOffset);
this._readingViewHighlightNodes = [];
const walker = document.createTreeWalker(readingEl, NodeFilter.SHOW_TEXT, null, false);
const textNodes = [];
while (walker.nextNode()) {
textNodes.push(walker.currentNode);
}
const searchLower = searchText.toLowerCase();
let matchIndex = 0;
for (const textNode of textNodes) {
const text = textNode.textContent;
const textLower = text.toLowerCase();
let pos = 0;
let matchPos;
const fragments = [];
let lastEnd = 0;
while ((matchPos = textLower.indexOf(searchLower, pos)) !== -1) {
if (matchPos > lastEnd) {
fragments.push(document.createTextNode(text.substring(lastEnd, matchPos)));
}
const mark = document.createElement('mark');
mark.className = `minimap-reading-match${this.getCounterPresetClass()}${hideCounter ? ' minimap-hide-counter' : ''}`;
mark.textContent = text.substring(matchPos, matchPos + searchText.length);
const matchOpacity = this.settings.matchOpacity ?? 0.6;
const c = this.hexToRgba(borderColor, matchOpacity);
mark.style.cssText = `margin:0 -0.2em;padding:0 0.2em;-webkit-box-decoration-break:clone;box-decoration-break:clone;background:radial-gradient(farthest-side,${c} 98%,#0000) bottom left,linear-gradient(${c} 0 0) bottom,radial-gradient(farthest-side,${c} 98%,#0000) bottom right;background-size:8px 8px,calc(100% - 8px) 8px;background-repeat:no-repeat;`;
mark.dataset.matchIndex = ++matchIndex;
mark.dataset.matchTotal = totalMatches;
mark.dataset.match = `${matchIndex}/${totalMatches}`;
fragments.push(mark);
this._readingViewHighlightNodes.push(mark);
lastEnd = matchPos + searchText.length;
pos = lastEnd;
}
if (fragments.length > 0) {
if (lastEnd < text.length) {
fragments.push(document.createTextNode(text.substring(lastEnd)));
}
const parent = textNode.parentNode;
if (parent) {
const span = document.createElement('span');
for (const f of fragments) {
span.appendChild(f);
}
parent.replaceChild(span, textNode);
}
}
}
// Set up MutationObserver to re-apply highlights when DOM is re-rendered
const self = this;
this._readingViewObserver = new MutationObserver((mutations) => {
// Check if our highlights were removed
if (!self.currentSelection || self._isApplyingReadingHighlights) return;
const existingMarks = readingEl.querySelectorAll('mark.minimap-reading-match');
if (existingMarks.length === 0) {
// Our highlights were removed, re-apply them
self._isApplyingReadingHighlights = true;
if (self._readingHighlightRetryTimer) clearTimeout(self._readingHighlightRetryTimer);
self._readingHighlightRetryTimer = setTimeout(() => {
if (self.currentSelection && self.isReadingMode()) {
self.addReadingViewHighlights(self.currentSelection, self._lastTotalMatches || 0);
}
}, 100);
}
});
this._readingViewObserver.observe(readingEl, {
childList: true,
subtree: true
});
this._lastTotalMatches = totalMatches;
// Clear the flag after a short delay to allow DOM changes to settle
setTimeout(() => {
this._isApplyingReadingHighlights = false;
}, 300);
}
clearReadingViewHighlights() {
// Disconnect observer before clearing
if (this._readingViewObserver) {
this._readingViewObserver.disconnect();
this._readingViewObserver = null;
}
if (this._readingHighlightRetryTimer) {
clearTimeout(this._readingHighlightRetryTimer);
this._readingHighlightRetryTimer = null;
}
const readingEl = this.getReadingViewContentEl();
if (!readingEl) return;
const marks = readingEl.querySelectorAll('mark.minimap-reading-match');
for (const mark of marks) {
const parent = mark.parentNode;
if (parent) {
parent.replaceChild(document.createTextNode(mark.textContent), mark);
parent.normalize();
}
}
// Clean up wrapper spans that we may have created
const wrapperSpans = readingEl.querySelectorAll('span');
for (const span of wrapperSpans) {
if (span.className === '' && span.parentNode && !span.dataset.matchIndex) {
const parent = span.parentNode;
while (span.firstChild) {
parent.insertBefore(span.firstChild, span);
}
parent.removeChild(span);
parent.normalize();
}
}
this._readingViewHighlightNodes = [];
}
hideAllMatchListIndicators() {
for (const indicator of this.matchListIndicators) {
if (indicator && indicator.parentNode) {
indicator.parentNode.removeChild(indicator);
}
}
this.matchListIndicators = [];
}
hideNonPinnedIndicators() {
const toRemove = [];
for (const indicator of this.matchListIndicators) {
if (!indicator.classList.contains('pinned')) {
if (indicator.parentNode) {
indicator.parentNode.removeChild(indicator);
}
toRemove.push(indicator);
} else {
indicator.classList.remove('current');
}
}
this.matchListIndicators = this.matchListIndicators.filter(ind => !toRemove.includes(ind));
}
showPinnedIndicators(selection) {
}
hidePinnedIndicators(selection) {
}
updatePinnedIndicatorsPosition() {
}
createMissingPinnedIndicators() {
}
async showAllPinnedIndicators() {
this.createMissingPinnedIndicators();
}
async saveCurrentMatchList(selection, matchCount) {
const file = this.app.workspace.activeLeaf?.view?.file;
if (!file) return;
const existingIndex = this.savedMatchLists.findIndex(m => m.selection === selection);
if (existingIndex >= 0) {
this.savedMatchLists[existingIndex].filePath = file.path;
this.savedMatchLists[existingIndex].fileName = file.basename;
this.savedMatchLists[existingIndex].matchCount = matchCount;
if (!this.isReadingMode()) {
const editor = this.getEditor();
if (editor) {
const cursor = editor.getCursor('from');
if (cursor) {
this.savedMatchLists[existingIndex].line = cursor.line;
this.savedMatchLists[existingIndex].ch = cursor.ch;
}
}
}
this.saveListData();
}
}
updateFloatingToggleBadge(docCount, matchCount) {
if (!this.floatingToggle) return;
const textEl = this.floatingToggleText;
if (!textEl) return;
const toggleText = this.settings.floatingToggleText || 'Swift';
const isPinned = this.currentSelection && this.savedMatchLists.some(m => m.selection === this.currentSelection && m.pinned);
if (!this.currentSelection) {
textEl.textContent = toggleText;
} else if (docCount > 0) {
const docStr = docCount > 99 ? '99+' : docCount;
const matchStr = matchCount > 0 ? (matchCount > 999 ? '999+' : matchCount) : '';
textEl.textContent = matchStr ? `${docStr}|${matchStr}` : `${docStr}`;
// Show pin indicator
if (isPinned) {
const pinColorScheme = this.savedMatchLists.find(m => m.selection === this.currentSelection && m.pinned);
if (pinColorScheme && pinColorScheme.borderColor) {
this.floatingToggle.style.boxShadow = `inset 0 0 0 2px ${pinColorScheme.borderColor}, 0 2px 10px rgba(0,0,0,0.05)`;
}
}
} else {
textEl.textContent = toggleText;
}
// Clear pin border if not pinned
if (!isPinned && this.floatingToggle) {
const activeStyle = this.getActiveFloatingStyle();
const hasCustomStyle = activeStyle ? !!(activeStyle.styleClass || activeStyle.customStyle) : !!(this.settings.floatingToggleStyleClass || this.settings.floatingToggleCustomStyle);
if (hasCustomStyle) {
this.floatingToggle.style.boxShadow = '';
} else {
this.floatingToggle.style.boxShadow = 'inset 0 0 0 1px rgba(240, 120, 100, 0.4), 0 2px 10px rgba(0, 0, 0, 0.05)';
}
}
// Re-apply strikethrough if disabled
if (!this.settings.enableSelectionMatch) {
textEl.style.textDecoration = 'line-through';
textEl.style.textDecorationColor = 'rgba(240, 100, 120, 0.7)';
}
}
async _performMatchListSearch(searchText, matchCount, cacheOnly = false) {
const cacheKey = searchText;
const savedData = this.savedMatchLists.find(m => m.selection === searchText);
if (savedData && savedData.pinned && savedData.fileMapData) {
const fileMap = await this.deserializeFileMap(savedData.fileMapData);
if (fileMap && fileMap.size > 0) {
this._cachedMatchList = fileMap;
this._cachedMatchListKey = cacheKey;
return;
}
}
if (this._cachedMatchList && this._cachedMatchListKey === cacheKey) {
return;
}
this._searchGeneration++;
const gen = this._searchGeneration;
this._searchInProgress = true;
const allFiles = this.app.vault.getMarkdownFiles();
const searchLower = searchText.toLowerCase();
const fileMap = new Map();
let totalAllDocMatches = 0;
for (const file of allFiles) {
if (this._searchGeneration !== gen) { this._searchInProgress = false; return; }
if (file.name.toLowerCase().includes(searchLower)) {
fileMap.set(file, [{ text: file.basename, level: 0, type: 'content' }]);
totalAllDocMatches += file.name.toLowerCase().split(searchLower).length - 1;
}
if (fileMap.size >= 50) break;
}
const remainingFiles = allFiles.filter(f => !fileMap.has(f));
for (const file of remainingFiles) {
if (this._searchGeneration !== gen) { this._searchInProgress = false; return; }
if (fileMap.size >= 50) break;
try {
const content = await this.app.vault.cachedRead(file);
if (this._searchGeneration !== gen) { this._searchInProgress = false; return; }
if (content && content.toLowerCase().includes(searchLower)) {
const lines = content.split('\n');
const snippets = [];
for (let i = 0; i < lines.length && snippets.length < 30; i++) {
const lineLower = lines[i].toLowerCase();
const idx = lineLower.indexOf(searchLower);
if (idx !== -1) {
const trimmed = lines[i].trim();
if (trimmed.length > 0) {
let snippet = trimmed;
let fullLine = trimmed;
let truncateStart = 0;
let truncateEnd = trimmed.length;
if (snippet.length > 120) {
const matchIdx = snippet.toLowerCase().indexOf(searchLower);
truncateStart = Math.max(0, matchIdx - 30);
truncateEnd = Math.min(snippet.length, matchIdx + searchLower.length + 70);
snippet = snippet.slice(truncateStart, truncateEnd);
}
snippets.push({ text: snippet, fullLine, truncateStart, truncateEnd });
}
}
}
if (snippets.length > 0) {
fileMap.set(file, [{ text: file.basename, level: 0, type: 'content', snippets }]);
totalAllDocMatches += content.toLowerCase().split(searchLower).length - 1;
} else {
fileMap.set(file, [{ text: file.basename, level: 0 }]);
}
}
} catch (e) {
continue;
}
}
if (this._searchGeneration !== gen) { this._searchInProgress = false; return; }
this._searchInProgress = false;
this._cachedMatchList = fileMap;
this._cachedMatchListKey = cacheKey;
this._pendingMatchCount = totalAllDocMatches;
if (cacheKey && fileMap && fileMap.size > 0) {
this._recentSearchCaches[cacheKey] = { fileMap, matchCount: totalAllDocMatches };
}
}
showMatchListFromFloatingToggle() {
this._listUserDismissed = false;
this._exhaustiveSearchDone = false;
this._listTriggerElement = this.floatingToggleWrapper || null;
const pendingTerm = this._pendingSearchText;
let restoreTerm, restoreFileMap, restoreMatchCount;
if (pendingTerm && pendingTerm !== this._lastListSearchTerm) {
restoreTerm = pendingTerm;
const memCached = this._recentSearchCaches[pendingTerm];
if (memCached && memCached.fileMap && memCached.fileMap.size > 0) {
restoreFileMap = memCached.fileMap;
restoreMatchCount = memCached.matchCount || this._pendingMatchCount;
} else if (this._cachedMatchListKey === pendingTerm && this._cachedMatchList) {
restoreFileMap = this._cachedMatchList;
restoreMatchCount = this._pendingMatchCount;
} else {
restoreFileMap = this._lastListFileMap || this._cachedMatchList;
restoreMatchCount = this._lastListMatchCount || this._pendingMatchCount;
}
} else {
restoreTerm = this._lastListSearchTerm || pendingTerm;
restoreFileMap = this._lastListFileMap || this._cachedMatchList;
restoreMatchCount = this._lastListMatchCount || this._pendingMatchCount;
}
if (!restoreFileMap || !restoreTerm) {
// If search is in progress, show the list and wait for results
if (this._searchInProgress && pendingTerm) {
this._isListVisible = true;
this._showMatchListLoading(pendingTerm);
this.positionListNearFloatingToggle();
}
return;
}
this._pendingShowList = { searchText: restoreTerm, matchCount: restoreMatchCount };
this._isListVisible = true;
this.renderMatchList(restoreFileMap, restoreMatchCount, false);
this._listShownFromHover = true;
// Position the list near the floating toggle
this.positionListNearFloatingToggle();
// Add mouseleave handler to hide list when mouse leaves both list and toggle
if (!this._matchListHoverLeaveHandler) {
this._matchListHoverLeaveHandler = () => {
setTimeout(() => {
const listHovered = this.matchList && this.matchList.matches(':hover');
const toggleHovered = this.floatingToggle && this.floatingToggle.matches(':hover');
const toggleWrapperHovered = this.floatingToggleWrapper && this.floatingToggleWrapper.matches(':hover');
const previewHovered = this.previewPanel && this.previewPanel.matches(':hover');
const keywordBtnHovered = this._floatingKeywordButtons.some(b => b.wrapper.matches(':hover'));
if (!listHovered && !toggleHovered && !toggleWrapperHovered && !previewHovered && !keywordBtnHovered) {
this.hideMatchList();
this._listShownFromHover = false;
}
}, 300);
};
this.matchList.addEventListener('mouseleave', this._matchListHoverLeaveHandler);
}
}
_createSearchChip(term, options = {}) {
const { isFavorite, isCurrentSearch, searchText } = options;
const chip = document.createElement('span');
chip.className = 'minimap-match-list-recent-chip';
const baseBg = isCurrentSearch ? 'var(--interactive-accent)' : 'var(--background-secondary)';
const baseColor = isCurrentSearch ? 'var(--text-on-accent)' : 'var(--text-muted)';
const baseBorder = isCurrentSearch ? 'var(--interactive-accent)' : 'var(--background-modifier-border)';
chip.style.cssText = `font-size:11px;padding:1px 6px;border-radius:8px;cursor:pointer;border:1px solid ${baseBorder};background:${baseBg};color:${baseColor};white-space:nowrap;transition:background 0.15s,color 0.15s;display:inline-flex;align-items:center;gap:3px;`;
chip.textContent = term;
const isFav = this._favoriteSearches.includes(term);
const chipStar = document.createElement('span');
chipStar.textContent = isFav ? '★' : '☆';
chipStar.style.cssText = `font-size:10px;cursor:pointer;color:${isFav ? '#ff6600' : 'var(--text-faint)'};transition:color 0.15s;line-height:1;flex-shrink:0;`;
chipStar.title = isFav ? t('removeFavorite') : t('addFavorite');
chipStar.addEventListener('mousedown', (e) => {
e.stopPropagation();
e.preventDefault();
this.isInteractingWithList = true;
});
chipStar.addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
const favIdx = this._favoriteSearches.indexOf(term);
if (favIdx >= 0) {
this._favoriteSearches.splice(favIdx, 1);
chipStar.textContent = '☆';
chipStar.style.color = 'var(--text-faint)';
chipStar.title = t('addFavorite');
} else {
this._favoriteSearches.push(term);
chipStar.textContent = '★';
chipStar.style.color = '#ff6600';
chipStar.title = t('removeFavorite');
}
this.saveListData();
const favSection = this.matchList?.querySelector('.swift-match-favorites-section');
if (favSection) {
const parent = favSection.parentNode;
parent.removeChild(favSection);
const newFavSection = this._createFavoritesSection(searchText);
const recentSec = this.matchList?.querySelector('.minimap-match-list-recent');
if (recentSec) {
parent.insertBefore(newFavSection, recentSec);
} else {
parent.appendChild(newFavSection);
}
}
});
chip.insertBefore(chipStar, chip.firstChild);
const chipPin = document.createElement('span');
const isAlreadyFloating = this._floatingKeywordButtons.some(b => b.term === term);
chipPin.textContent = '📌';
chipPin.style.cssText = `font-size:9px;cursor:pointer;opacity:${isAlreadyFloating ? '1' : '0.3'};transition:opacity 0.15s;line-height:1;flex-shrink:0;`;
chipPin.title = isAlreadyFloating ? t('removeFloat') : t('floatThisWord');
chipPin.addEventListener('mousedown', (e) => {
e.stopPropagation();
e.preventDefault();
this.isInteractingWithList = true;
});
chipPin.addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
const isFloating = this._floatingKeywordButtons.some(b => b.term === term);
if (isFloating) {
this.removeFloatingKeywordButton(term);
chipPin.style.opacity = '0.3';
chipPin.title = t('floatThisWord');
} else {
const cached = this._recentSearchCaches[term];
if (cached && cached.fileMap && cached.fileMap.size > 0) {
this.createFloatingKeywordButton(term, cached.fileMap, cached.matchCount);
} else {
this.createFloatingKeywordButton(term, new Map(), 0);
}
chipPin.style.opacity = '1';
chipPin.title = t('removeFloat');
}
});
chip.appendChild(chipPin);
chip.addEventListener('mousedown', (e) => {
if (e.target === chipStar || e.target === chipPin) return;
e.stopPropagation();
this.isInteractingWithList = true;
if (e.button === 1) {
e.preventDefault();
if (isFavorite) {
const favIdx = this._favoriteSearches.indexOf(term);
if (favIdx >= 0) this._favoriteSearches.splice(favIdx, 1);
delete this._recentSearchCaches[term];
this.deleteSearchCache(term);
delete this._listScrollPositions[term];
} else {
const idx = this._recentSearches.indexOf(term);
if (idx >= 0) this._recentSearches.splice(idx, 1);
if (!this._favoriteSearches.includes(term)) {
delete this._recentSearchCaches[term];
this.deleteSearchCache(term);
delete this._listScrollPositions[term];
}
}
this.saveListData();
chip.remove();
}
});
chip.addEventListener('click', async (e) => {
if (e.target === chipStar || e.target === chipPin) return;
e.stopPropagation();
this.isInteractingWithList = true;
this._keepListVisible = true;
this._chipSwitching = true;
clearTimeout(this._chipSwitchTimer);
this._chipSwitchTimer = setTimeout(() => { this._chipSwitching = false; }, 5000);
this.currentSelection = term;
this._listUserDismissed = false;
this._exhaustiveSearchDone = false;
this._pendingSearchText = term;
if (this._searchInProgress) this._searchGeneration++;
let cached = this._recentSearchCaches[term];
if (!cached || !cached.fileMap || cached.fileMap.size === 0) {
const diskCache = await this.loadSearchCache(term);
if (diskCache && diskCache.fileMap && diskCache.fileMap.size > 0) {
cached = diskCache;
this._recentSearchCaches[term] = diskCache;
}
}
if (cached && cached.fileMap && cached.fileMap.size > 0) {
this._cachedMatchList = cached.fileMap;
this._cachedMatchListKey = term;
this._pendingMatchCount = cached.matchCount;
this._pendingShowList = { searchText: term, matchCount: cached.matchCount };
this.highlightMatches();
if (this._isListVisible) {
this.renderMatchList(cached.fileMap, cached.matchCount, false);
}
this.updateFloatingToggleBadge(cached.fileMap.size, cached.matchCount);
} else {
this._pendingShowList = { searchText: term, matchCount: 0 };
this._cachedMatchList = null;
this._cachedMatchListKey = null;
this._pendingMatchCount = 0;
if (this._isListVisible) {
this.renderMatchList(new Map(), 0, false);
}
this.updateFloatingToggleBadge(0, 0);
this.highlightMatches();
}
this._keepListVisible = false;
});
chip.addEventListener('mouseenter', () => {
if (!isCurrentSearch) {
chip.style.background = 'var(--background-modifier-hover)';
chip.style.color = 'var(--text-normal)';
}
});
chip.addEventListener('mouseleave', () => {
if (!isCurrentSearch) {
chip.style.background = 'var(--background-secondary)';
chip.style.color = 'var(--text-muted)';
}
});
return chip;
}
_createFavoritesSection(searchText) {
const section = document.createElement('div');
section.className = 'swift-match-favorites-section';
section.style.cssText = 'padding:4px 8px;border-bottom:1px solid var(--background-modifier-border);max-height:80px;overflow-y:auto;display:flex;flex-wrap:wrap;align-items:center;gap:4px;';
const label = document.createElement('span');
label.style.cssText = 'font-size:10px;color:var(--text-faint);flex-shrink:0;';
label.textContent = t('favoriteSearch');
section.appendChild(label);
for (const term of this._favoriteSearches) {
const isCurrentSearch = term === searchText;
const chip = this._createSearchChip(term, { isFavorite: true, isCurrentSearch, searchText });
section.appendChild(chip);
}
if (this._favoriteSearches.length === 0) {
section.style.display = 'none';
}
if (this._favSectionScrollTop) {
requestAnimationFrame(() => { section.scrollTop = this._favSectionScrollTop; });
}
return section;
}
showRecentSearchList() {
if (!this.matchList) return;
this.matchList.innerHTML = '';
const header = document.createElement('div');
header.className = 'minimap-match-list-header';
header.addEventListener('mousedown', (e) => {
this.isInteractingWithList = true;
});
const headerText = document.createElement('span');
headerText.className = 'minimap-match-list-header-text';
headerText.textContent = t('recentSearch');
header.appendChild(headerText);
if (this.floatingSearchBox) {
this.floatingSearchBox.placeholder = t('searchPlaceholder');
this.floatingSearchBox.style.marginLeft = '8px';
header.appendChild(this.floatingSearchBox);
}
this.matchList.appendChild(header);
// Favorites section
const favSection = this._createFavoritesSection('');
this.matchList.appendChild(favSection);
// Recent searches section
const recentSection = document.createElement('div');
recentSection.className = 'minimap-match-list-recent';
recentSection.style.cssText = 'padding:6px 8px;overflow-y:auto;display:flex;flex-wrap:wrap;align-items:center;gap:4px;';
const recentLabel = document.createElement('span');
recentLabel.style.cssText = 'font-size:10px;color:var(--text-faint);flex-shrink:0;';
recentLabel.textContent = t('recentSearch');
recentSection.appendChild(recentLabel);
const searchesToShow = this._recentSearches.slice(0, 20);
for (const term of searchesToShow) {
const chipWrapper = this._createSearchChip(term, { searchText: '' });
recentSection.appendChild(chipWrapper);
}
this.matchList.appendChild(recentSection);
// Add resize handle
const resizeHandle = document.createElement('div');
resizeHandle.className = 'minimap-match-list-resize-handle';
this.matchList.appendChild(resizeHandle);
this.matchList.style.display = 'block';
this.matchList.style.opacity = this.listOpacity;
this._isListVisible = true;
this._listShownFromHover = true;
this.positionListNearFloatingToggle();
if (this.floatingSearchBox) {
setTimeout(() => { this.floatingSearchBox.focus(); }, 50);
}
// Add mouseleave handler
if (!this._matchListHoverLeaveHandler) {
this._matchListHoverLeaveHandler = () => {
setTimeout(() => {
const listHovered = this.matchList && this.matchList.matches(':hover');
const toggleHovered = this.floatingToggle && this.floatingToggle.matches(':hover');
const toggleWrapperHovered = this.floatingToggleWrapper && this.floatingToggleWrapper.matches(':hover');
const previewHovered = this.previewPanel && this.previewPanel.matches(':hover');
const keywordBtnHovered = this._floatingKeywordButtons.some(b => b.wrapper.matches(':hover'));
if (!listHovered && !toggleHovered && !toggleWrapperHovered && !previewHovered && !keywordBtnHovered) {
this.hideMatchList();
this._listShownFromHover = false;
}
}, 300);
};
this.matchList.addEventListener('mouseleave', this._matchListHoverLeaveHandler);
}
}
/**
* Count words/characters for search limit checking.
* - Pure CJK text (no spaces): count by characters (each CJK char = 1 unit)
* - Mixed or Latin text: count by whitespace-separated words
* This makes searchWordCountMin/Max work correctly for both Chinese and English.
*/
countWords(text) {
if (!text) return 0;
const trimmed = text.trim();
if (!trimmed) return 0;
// If text contains CJK characters and no spaces, count CJK chars + non-CJK tokens
const hasCJK = /[\u4e00-\u9fff\u3400-\u4dbf]/.test(trimmed);
const hasWhitespace = /\s/.test(trimmed);
if (hasCJK && !hasWhitespace) {
// Pure CJK (or CJK + punctuation without spaces): count characters
return trimmed.length;
}
if (hasCJK) {
// Mixed CJK + Latin with spaces: count CJK chars individually + Latin words
let count = 0;
// Count CJK characters
const cjkMatches = trimmed.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g);
if (cjkMatches) count += cjkMatches.length;
// Count Latin words (non-CJK, non-space sequences)
const latinMatches = trimmed.match(/[^\s\u4e00-\u9fff\u3400-\u4dbf]+/g);
if (latinMatches) count += latinMatches.length;
return count;
}
// Pure Latin: count whitespace-separated words
return trimmed.split(/\s+/).length;
}
async showMatchList(searchText, matchCount) {
if (!this.matchList) return;
if (!this.settings.enableSelectionMatch) return;
// Check word count limits before proceeding with background search
const wordLen = this.countWords(searchText);
const minWords = this.settings.searchWordCountMin ?? 0;
const maxWords = this.settings.searchWordCountMax ?? 0;
if (minWords > 0 && wordLen < minWords) return;
if (maxWords > 0 && wordLen > maxWords) return;
// Reset exhaustive search flag when search text changes
if (this._pendingSearchText !== searchText) {
this._exhaustiveSearchDone = false;
}
// Always cache data and update badge, never auto-show the list
this._pendingMatchCount = matchCount;
this._pendingSearchText = searchText;
// Record to recent searches (skip reorder when switching via chip)
if (searchText && searchText.length > 0) {
const idx = this._recentSearches.indexOf(searchText);
if (idx >= 0) {
if (!this._chipSwitching) {
this._recentSearches.splice(idx, 1);
this._recentSearches.unshift(searchText);
}
} else {
this._recentSearches.unshift(searchText);
}
if (this._recentSearches.length > 20) {
const removed = this._recentSearches.pop();
if (!this._favoriteSearches.includes(removed)) {
delete this._recentSearchCaches[removed];
this.deleteSearchCache(removed);
delete this._listScrollPositions[removed];
}
}
}
// When list is pinned to a specific search text
if (this._listPinnedSearchText && this._listPinnedSearchText !== searchText) {
this._pendingShowList = { searchText, matchCount };
const mk = this.parseMultiKeywords(searchText);
if (mk) {
this._multiKeywordData = null;
this._showMatchListLoading(searchText);
this.performMultiKeywordSearch(searchText, mk);
} else if (this.settings.exhaustiveMode) {
this.performExhaustiveSearch(searchText, matchCount, null);
} else {
this._performMatchListSearch(searchText, matchCount, true).then(() => {
const docCount = this._cachedMatchList ? this._cachedMatchList.size : 0;
this.updateFloatingToggleBadge(docCount, matchCount);
});
}
return;
}
// Multi-keyword search (pipe-separated)
const multiKeywords = this.parseMultiKeywords(searchText);
if (multiKeywords) {
this._pendingShowList = { searchText, matchCount };
this._multiKeywordData = null;
if (!this._exhaustiveSearchDone) {
this._exhaustiveSearchDone = true;
this._showMatchListLoading(searchText);
this.performMultiKeywordSearch(searchText, multiKeywords);
} else if (this._cachedMatchList && this._cachedMatchListKey === searchText) {
this.updateFloatingToggleBadge(this._cachedMatchList.size, this._pendingMatchCount || matchCount);
if (this._isListVisible) {
this.renderMatchList(this._cachedMatchList, this._pendingMatchCount || matchCount, false);
}
}
return;
}
// In exhaustive mode, skip heading cache and use full-text search directly
if (this.settings.exhaustiveMode) {
this._pendingShowList = { searchText, matchCount };
if (!this._exhaustiveSearchDone) {
this._exhaustiveSearchDone = true;
this._showMatchListLoading(searchText);
this.performExhaustiveSearch(searchText, matchCount, null);
}
return;
}
// Perform search in background, update badge with doc count
const cacheKey = searchText;
const savedData = this.savedMatchLists.find(m => m.selection === searchText);
if (savedData && savedData.pinned && savedData.fileMapData) {
const fileMap = await this.deserializeFileMap(savedData.fileMapData);
if (fileMap && fileMap.size > 0) {
this._pendingShowList = { searchText, matchCount };
this._cachedMatchList = fileMap;
this._cachedMatchListKey = cacheKey;
this._recentSearchCaches[searchText] = { fileMap, matchCount };
this.updateFloatingToggleBadge(fileMap.size, matchCount);
if (this._searchInProgress) this._searchGeneration++;
if (this._isListVisible) {
this.renderMatchList(fileMap, matchCount, false);
}
return;
}
}
if (this._cachedMatchList && this._cachedMatchListKey === cacheKey) {
this._pendingShowList = { searchText, matchCount };
this.updateFloatingToggleBadge(this._cachedMatchList.size, matchCount);
if (this._searchInProgress) this._searchGeneration++;
if (this._isListVisible) {
this.renderMatchList(this._cachedMatchList, matchCount, false);
}
return;
}
const memCached = this._recentSearchCaches[searchText];
if (memCached && memCached.fileMap && memCached.fileMap.size > 0) {
this._cachedMatchList = memCached.fileMap;
this._cachedMatchListKey = cacheKey;
this._pendingShowList = { searchText, matchCount };
this.updateFloatingToggleBadge(memCached.fileMap.size, memCached.matchCount || matchCount);
if (this._searchInProgress) this._searchGeneration++;
if (this._isListVisible) {
this.renderMatchList(memCached.fileMap, memCached.matchCount || matchCount, false);
}
return;
}
const diskCache = await this.loadSearchCache(searchText);
if (diskCache && diskCache.fileMap && diskCache.fileMap.size > 0) {
this._cachedMatchList = diskCache.fileMap;
this._cachedMatchListKey = cacheKey;
this._recentSearchCaches[searchText] = diskCache;
this._pendingShowList = { searchText, matchCount };
this.updateFloatingToggleBadge(diskCache.fileMap.size, diskCache.matchCount || matchCount);
if (this._searchInProgress) this._searchGeneration++;
if (this._isListVisible) {
this.renderMatchList(diskCache.fileMap, diskCache.matchCount || matchCount, false);
}
return;
}
this._searchGeneration++;
const gen = this._searchGeneration;
this._searchInProgress = true;
this._pendingShowList = { searchText, matchCount };
this._showMatchListLoading(searchText);
const allFiles = this.app.vault.getMarkdownFiles();
const searchLower = searchText.toLowerCase();
const fileMap = new Map();
let totalAllDocMatches = 0;
for (const file of allFiles) {
if (this._searchGeneration !== gen) { this._searchInProgress = false; return; }
if (file.name.toLowerCase().includes(searchLower)) {
fileMap.set(file, [{ text: file.basename, level: 0, type: 'content' }]);
totalAllDocMatches += file.name.toLowerCase().split(searchLower).length - 1;
}
if (fileMap.size >= 50) break;
}
if (fileMap.size > 0 && this._searchGeneration === gen) {
this.updateFloatingToggleBadge(fileMap.size, totalAllDocMatches);
if (this._isListVisible) {
this.renderMatchList(fileMap, matchCount, false);
}
}
const remainingFiles = allFiles.filter(f => !fileMap.has(f));
for (const file of remainingFiles) {
if (this._searchGeneration !== gen) { this._searchInProgress = false; return; }
try {
const content = await this.app.vault.read(file);
if (this._searchGeneration !== gen) { this._searchInProgress = false; return; }
if (content && content.toLowerCase().includes(searchLower)) {
const lines = content.split('\n');
const snippets = [];
for (let i = 0; i < lines.length && snippets.length < 30; i++) {
const lineLower = lines[i].toLowerCase();
const idx = lineLower.indexOf(searchLower);
if (idx !== -1) {
const trimmed = lines[i].trim();
if (trimmed.length > 0) {
let snippet = trimmed;
let fullLine = trimmed;
let truncateStart = 0;
let truncateEnd = trimmed.length;
if (snippet.length > 120) {
const matchIdx = snippet.toLowerCase().indexOf(searchLower);
truncateStart = Math.max(0, matchIdx - 30);
truncateEnd = Math.min(snippet.length, matchIdx + searchLower.length + 70);
snippet = snippet.slice(truncateStart, truncateEnd);
}
snippets.push({ text: snippet, fullLine, truncateStart, truncateEnd });
}
}
}
if (snippets.length > 0 && this._searchGeneration === gen) {
fileMap.set(file, [{ text: file.basename, level: 0, type: 'content', snippets }]);
totalAllDocMatches += content.toLowerCase().split(searchLower).length - 1;
this.updateFloatingToggleBadge(fileMap.size, totalAllDocMatches);
if (this._isListVisible) {
const isFirstMatch = fileMap.size === 1;
this.renderMatchList(fileMap, matchCount, !isFirstMatch);
}
}
}
} catch (e) {
continue;
}
if (fileMap.size >= 50) break;
}
if (this._searchGeneration === gen) {
this._cachedMatchList = fileMap;
this._cachedMatchListKey = cacheKey;
this._recentSearchCaches[searchText] = { fileMap, matchCount: totalAllDocMatches };
this._pendingMatchCount = totalAllDocMatches;
await this.saveMatchListData(searchText, fileMap, totalAllDocMatches);
if (this._isListVisible) {
this.renderMatchList(fileMap, totalAllDocMatches, false);
}
}
this._searchInProgress = false;
this._pendingMatchCount = totalAllDocMatches;
return fileMap;
}
async saveMatchListData(selection, fileMap, matchCount) {
const fileMapData = await this.serializeFileMap(fileMap);
const existingIndex = this.savedMatchLists.findIndex(m => m.selection === selection);
const matchData = {
selection: selection,
matchCount: matchCount,
fileMapData: fileMapData,
timestamp: Date.now()
};
if (existingIndex >= 0) {
this.savedMatchLists[existingIndex] = matchData;
} else {
this.savedMatchLists.unshift(matchData);
if (this.savedMatchLists.length > 100) {
this.savedMatchLists = this.savedMatchLists.slice(0, 100);
}
}
this.saveListData();
}
async serializeFileMap(fileMap) {
const data = [];
for (const [file, headings] of fileMap) {
data.push({
path: file.path,
basename: file.basename,
headings: headings
});
}
return data;
}
async deserializeFileMap(data) {
const fileMap = new Map();
const allFiles = this.app.vault.getMarkdownFiles();
for (const item of data) {
const file = allFiles.find(f => f.path === item.path);
if (file) {
fileMap.set(file, item.headings);
}
}
return fileMap;
}
cancelSearch() {
this._searchGeneration++;
}
async performExhaustiveSearch(searchText, matchCount, headerTextEl) {
this._searchGeneration++;
const gen = this._searchGeneration;
this._searchInProgress = true;
const fileMap = new Map();
const searchLower = searchText.toLowerCase();
let totalAllDocMatches = 0;
const extractSnippets = (content, searchLower, maxSnippets = 30) => {
const lines = content.split('\n');
const snippets = [];
for (let i = 0; i < lines.length && snippets.length < maxSnippets; i++) {
const lineLower = lines[i].toLowerCase();
const idx = lineLower.indexOf(searchLower);
if (idx !== -1) {
const trimmed = lines[i].trim();
if (trimmed.length > 0) {
let snippet = trimmed;
let fullLine = trimmed;
let truncateStart = 0;
let truncateEnd = trimmed.length;
if (snippet.length > 120) {
const matchIdx = snippet.toLowerCase().indexOf(searchLower);
truncateStart = Math.max(0, matchIdx - 30);
truncateEnd = Math.min(snippet.length, matchIdx + searchLower.length + 70);
snippet = snippet.slice(truncateStart, truncateEnd);
}
snippets.push({ text: snippet, fullLine, truncateStart, truncateEnd });
}
}
}
return snippets;
};
try {
const searchPlugin = this.app.internalPlugins.plugins['search'];
if (searchPlugin && searchPlugin.instance) {
const rawResults = searchPlugin.instance.search(searchText);
if (rawResults) {
for (const result of rawResults) {
const file = this.app.vault.getAbstractFileByPath(result.path);
if (file && file.extension === 'md') {
const items = [];
if (result.matches) {
for (const m of result.matches) {
const snippetText = m.match || searchText;
items.push({ text: snippetText, level: 0, type: 'content', snippet: snippetText });
}
}
if (items.length === 0) {
items.push({ text: file.basename, level: 0 });
}
totalAllDocMatches += result.matches ? result.matches.length : 1;
fileMap.set(file, items);
}
}
}
}
} catch (e) {
}
if (this._searchGeneration !== gen) { this._searchInProgress = false; return; }
if (fileMap.size === 0) {
const allFiles = this.app.vault.getMarkdownFiles();
for (const file of allFiles) {
if (this._searchGeneration !== gen) { this._searchInProgress = false; return; }
try {
const nameMatch = file.name.toLowerCase().includes(searchLower);
const content = await this.app.vault.cachedRead(file);
if (this._searchGeneration !== gen) { this._searchInProgress = false; return; }
const contentMatch = content && content.toLowerCase().includes(searchLower);
if (nameMatch || contentMatch) {
const snippets = contentMatch ? extractSnippets(content, searchLower) : [];
const items = [{ text: file.basename, level: 0, type: 'content' }];
if (snippets.length > 0) {
items[0].snippets = snippets;
}
totalAllDocMatches += contentMatch ? (content.toLowerCase().split(searchLower).length - 1) : 1;
fileMap.set(file, items);
}
} catch (e) {
continue;
}
if (fileMap.size >= 200) break;
}
}
if (this._searchGeneration !== gen) { this._searchInProgress = false; return; }
if (fileMap.size > 0) {
for (const [file, items] of fileMap) {
if (this._searchGeneration !== gen) { this._searchInProgress = false; return; }
const hasContentSnippets = items.some(item => item.type === 'content' && (item.snippet || item.snippets));
if (!hasContentSnippets) {
try {
const content = await this.app.vault.cachedRead(file);
if (this._searchGeneration !== gen) { this._searchInProgress = false; return; }
if (content) {
const snippets = extractSnippets(content, searchLower);
for (const item of items) {
if (item.type === 'content' && !item.snippet && !item.snippets) {
item.snippets = snippets;
break;
}
}
}
} catch (e) {
}
}
}
}
if (this._searchGeneration !== gen) { this._searchInProgress = false; return; }
this._searchInProgress = false;
this._cachedMatchList = fileMap;
this._cachedMatchListKey = searchText;
this._pendingShowList = { searchText, matchCount };
this._pendingMatchCount = totalAllDocMatches;
this._recentSearchCaches[searchText] = { fileMap, matchCount: totalAllDocMatches };
this.updateFloatingToggleBadge(fileMap.size, totalAllDocMatches);
// If list is already visible, refresh it
if (this._isListVisible) {
this.renderMatchList(fileMap, totalAllDocMatches, false);
}
}
_showMatchListLoading(searchText) {
if (!this.matchList) return;
if (!this._isListVisible) {
// Don't auto-show the list; just prepare data in the background
return;
} else {
const container = this.matchList.querySelector('.minimap-match-list-container');
if (container) {
container.innerHTML = '';
const loading = document.createElement('div');
loading.style.cssText = 'padding:16px;text-align:center;color:var(--text-muted);font-size:12px;';
loading.textContent = t('building');
container.appendChild(loading);
}
}
}
renderMatchList(fileMap, matchCount, append = false) {
const searchText = this._pendingShowList?.searchText || '';
// Restore _multiKeywordData if it was cleared but searchText is multi-keyword
if (!this._multiKeywordData && searchText) {
const mk = this.parseMultiKeywords(searchText);
if (mk) {
const keywordColors = this.getMultiKeywordColors(mk.length);
this._multiKeywordData = { keywords: mk, keywordColors };
}
}
if (fileMap.size === 0) {
if (!append && this._pendingShowList?.searchText) {
this.matchList.innerHTML = '';
const header = document.createElement('div');
header.className = 'minimap-match-list-header';
header.addEventListener('mousedown', (e) => {
this.isInteractingWithList = true;
});
const headerText = document.createElement('span');
headerText.className = 'minimap-match-list-header-text';
headerText.textContent = `${this._pendingShowList.searchText}`;
header.appendChild(headerText);
if (this.floatingSearchBox) {
this.floatingSearchBox.placeholder = t('searchPlaceholder');
this.floatingSearchBox.style.marginLeft = '8px';
header.appendChild(this.floatingSearchBox);
}
this.matchList.appendChild(header);
const favSec = this._createFavoritesSection(this._pendingShowList.searchText);
if (favSec) this.matchList.appendChild(favSec);
const recentSec = document.createElement('div');
recentSec.className = 'minimap-match-list-recent';
recentSec.style.cssText = 'padding:4px 8px;border-bottom:1px solid var(--background-modifier-border);max-height:80px;overflow-y:auto;display:flex;flex-wrap:wrap;align-items:center;gap:4px;';
const recentLabel = document.createElement('span');
recentLabel.style.cssText = 'font-size:10px;color:var(--text-faint);flex-shrink:0;';
recentLabel.textContent = t('recentSearch');
recentSec.appendChild(recentLabel);
const searchesToShow = this._recentSearches.slice(0, 20);
for (const rTerm of searchesToShow) {
const chip = this._createSearchChip(rTerm, { isCurrentSearch: rTerm === this._pendingShowList.searchText, searchText: this._pendingShowList.searchText });
recentSec.appendChild(chip);
}
this.matchList.appendChild(recentSec);
const emptyContainer = document.createElement('div');
emptyContainer.className = 'minimap-match-list-container';
emptyContainer.style.cssText = 'padding:12px;text-align:center;color:var(--text-faint);font-size:12px;';
emptyContainer.textContent = '...';
this.matchList.appendChild(emptyContainer);
if (this._isListVisible) {
this.matchList.style.display = 'flex';
}
} else {
this.matchList.style.display = 'none';
}
return;
}
if (!this._pendingShowList) {
return;
}
if (!append) {
// Preserve resize handle and scroll position when clearing
const existingResizeHandle = this.matchList.querySelector('.minimap-match-list-resize-handle');
const listContainer = this.matchList.querySelector('.minimap-match-list-container');
// Only save current scrollTop if list is visible (display: none yields scrollTop=0)
if (listContainer && this.matchList.style.display !== 'none') {
this._savedListScrollTop = listContainer.scrollTop;
}
this.matchList.innerHTML = '';
const header = document.createElement('div');
header.className = 'minimap-match-list-header';
header.addEventListener('mousedown', (e) => {
this.isInteractingWithList = true;
});
const headerText = document.createElement('span');
headerText.className = 'minimap-match-list-header-text';
const totalMatchCount = this._pendingMatchCount || matchCount;
headerText.textContent = this.settings.exhaustiveMode
? t('exhaustiveMatch', fileMap.size, totalMatchCount)
: t('libraryMatch', fileMap.size, totalMatchCount);
header.appendChild(headerText);
// Mark/pin toggle removed per user request
// Add save/export button
const saveBtn = document.createElement('button');
saveBtn.className = 'minimap-match-list-save-btn';
saveBtn.textContent = t('save');
saveBtn.style.cssText = 'margin-left:8px;padding:2px 8px;font-size:11px;cursor:pointer;border:1px solid var(--background-modifier-border);border-radius:3px;background:var(--background-secondary);color:var(--text-muted);transition:color 0.15s,background 0.15s;flex-shrink:0;';
saveBtn.title = t('exportMarkdown');
saveBtn.addEventListener('mousedown', (e) => {
e.stopPropagation();
this.isInteractingWithList = true;
});
saveBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.exportMatchList(fileMap, searchText, matchCount);
});
saveBtn.addEventListener('mouseenter', () => {
saveBtn.style.color = 'var(--text-normal)';
saveBtn.style.background = 'var(--background-modifier-hover)';
});
saveBtn.addEventListener('mouseleave', () => {
saveBtn.style.color = 'var(--text-muted)';
saveBtn.style.background = 'var(--background-secondary)';
});
header.appendChild(saveBtn);
if (this.floatingSearchBox) {
this.floatingSearchBox.placeholder = t('searchPlaceholder');
this.floatingSearchBox.style.marginLeft = '8px';
header.appendChild(this.floatingSearchBox);
}
this.matchList.appendChild(header);
// Favorites section
const favSection = this._createFavoritesSection(searchText);
this.matchList.appendChild(favSection);
// Recent searches section in header
const recentSection = document.createElement('div');
recentSection.className = 'minimap-match-list-recent';
recentSection.style.cssText = 'padding:4px 8px;border-bottom:1px solid var(--background-modifier-border);max-height:80px;overflow-y:auto;display:flex;flex-wrap:wrap;align-items:center;gap:4px;';
const recentLabel = document.createElement('span');
recentLabel.style.cssText = 'font-size:10px;color:var(--text-faint);flex-shrink:0;';
recentLabel.textContent = t('recentSearch');
recentSection.appendChild(recentLabel);
const searchesToShow = this._recentSearches.slice(0, 20);
for (const term of searchesToShow) {
const isCurrentSearch = term === searchText;
const chip = this._createSearchChip(term, { isCurrentSearch, searchText });
recentSection.appendChild(chip);
}
this.matchList.appendChild(recentSection);
const newContainer = document.createElement('div');
newContainer.className = 'minimap-match-list-container';
// Save scroll position on every scroll event for robust restoration
newContainer.addEventListener('scroll', () => {
const currentTerm = this._pendingShowList?.searchText || this._pendingSearchText;
if (currentTerm) {
this._listScrollPositions[currentTerm] = newContainer.scrollTop;
}
this._matchListScrollTop = newContainer.scrollTop;
});
this.matchList.appendChild(newContainer);
}
const listContainer = this.matchList.querySelector('.minimap-match-list-container');
if (!listContainer) return;
const existingPaths = new Set();
listContainer.querySelectorAll('.swift-match-group').forEach(el => {
existingPaths.add(el.dataset.filePath);
});
// 文件颜色方案(不同文件用不同色相)
const fileHues = [210, 30, 150, 340, 270, 60, 180, 90];
let globalFileIndex = existingPaths.size;
fileMap.forEach((items, file) => {
if (existingPaths.has(file.path)) return;
const fileHue = fileHues[globalFileIndex % fileHues.length];
const fileNameBg = `hsl(${fileHue},50%,90%)`;
const fileNameColor = `hsl(${fileHue},50%,35%)`;
const dotColor = `hsl(${fileHue},50%,50%)`;
// 文件分组容器
const groupEl = document.createElement('div');
groupEl.className = 'swift-match-group';
groupEl.dataset.filePath = file.path;
groupEl.style.cssText = globalFileIndex > existingPaths.size
? 'margin-top:8px;padding-top:8px;border-top:1px solid var(--background-modifier-border);'
: '';
// 分组标题栏:文件名 + 匹配数量
const headerEl = document.createElement('div');
headerEl.style.cssText = 'display:flex;align-items:center;gap:6px;margin-bottom:4px;flex-wrap:wrap;cursor:pointer;';
const fileNameEl = document.createElement('span');
fileNameEl.textContent = file.path;
fileNameEl.style.cssText = `font-size:12px;font-weight:600;color:${fileNameColor};background:${fileNameBg};padding:2px 8px;border-radius:10px;flex-shrink:0;white-space:nowrap;max-width:100%;overflow:hidden;text-overflow:ellipsis;`;
let totalEntries = 0;
items.forEach(item => {
if (item.type === 'content' && (item.snippet || (item.snippets && item.snippets.length > 0))) {
totalEntries += (item.snippets || (item.snippet ? [item.snippet] : [])).length;
} else {
totalEntries++;
}
});
const countEl = document.createElement('span');
countEl.textContent = `${totalEntries}`;
countEl.style.cssText = `font-size:10px;color:var(--text-faint);background:var(--background-secondary);padding:1px 5px;border-radius:8px;flex-shrink:0;`;
const expandIcon = document.createElement('span');
const isExpanded = this._expandedGroups?.[file.path];
expandIcon.textContent = isExpanded ? '▼' : '▶';
expandIcon.style.cssText = 'font-size:8px;color:var(--text-faint);flex-shrink:0;transition:transform 0.15s;';
headerEl.appendChild(expandIcon);
headerEl.appendChild(fileNameEl);
// Multi-keyword tags
const mkData = this._multiKeywordData;
if (mkData) {
const matchedKeywords = new Set();
items.forEach(item => {
if (item.keyword) matchedKeywords.add(item.keywordIndex);
});
const tagContainer = document.createElement('span');
tagContainer.style.cssText = 'display:flex;gap:3px;flex-wrap:wrap;flex-shrink:0;';
for (let ki = 0; ki < mkData.keywords.length; ki++) {
const tag = document.createElement('span');
const isMatched = matchedKeywords.has(ki);
tag.textContent = mkData.keywords[ki];
tag.style.cssText = `font-size:9px;padding:1px 5px;border-radius:6px;white-space:nowrap;${isMatched ? `background:${mkData.keywordColors[ki]}22;color:${mkData.keywordColors[ki]};border:1px solid ${mkData.keywordColors[ki]}44;` : 'background:var(--background-secondary);color:var(--text-faint);border:1px solid var(--background-modifier-border);'}`;
tagContainer.appendChild(tag);
}
headerEl.appendChild(tagContainer);
}
headerEl.appendChild(countEl);
headerEl.addEventListener('mousedown', (e) => {
e.stopPropagation();
e.preventDefault();
this.isInteractingWithList = true;
});
headerEl.addEventListener('click', (e) => {
e.stopPropagation();
if (!this._expandedGroups) this._expandedGroups = {};
this._expandedGroups[file.path] = !this._expandedGroups[file.path];
expandIcon.textContent = this._expandedGroups[file.path] ? '▼' : '▶';
const allItems = bodyEl.querySelectorAll('.swift-match-item:not(.swift-match-more-line):not(.swift-match-divider)');
allItems.forEach((el, idx) => {
if (idx >= 3) {
el.style.display = this._expandedGroups[file.path] ? 'flex' : 'none';
}
});
const allDividers = bodyEl.querySelectorAll('.swift-match-divider');
allDividers.forEach((el, idx) => {
if (idx >= 2) {
el.style.display = this._expandedGroups[file.path] ? 'block' : 'none';
}
});
const moreLine = bodyEl.querySelector('.swift-match-more-line');
if (moreLine) moreLine.style.display = this._expandedGroups[file.path] ? 'none' : 'flex';
});
groupEl.appendChild(headerEl);
// 分组内容区
const bodyEl = document.createElement('div');
bodyEl.className = 'swift-match-group-body';
let globalEntryIdx = 0;
items.forEach((item, i) => {
// 将 content 类型的 snippets 拆分为独立条目
let matchEntries = [];
if (item.type === 'content' && (item.snippet || (item.snippets && item.snippets.length > 0))) {
const contentSnippets = item.snippets || (item.snippet ? [item.snippet] : []);
for (const snippetData of contentSnippets) {
if (typeof snippetData === 'object' && snippetData.text !== undefined) {
matchEntries.push({ text: snippetData.text, isMarkdown: true, type: 'content', snippetData });
} else {
matchEntries.push({ text: String(snippetData), isMarkdown: true, type: 'content', snippetData: null });
}
}
} else if (item.type === 'tag') {
matchEntries.push({ text: `#${item.text}`, isMarkdown: false, type: 'tag' });
} else if (item.level > 0) {
matchEntries.push({ text: item.text, isMarkdown: true, type: 'heading' });
} else {
matchEntries.push({ text: item.text || '', isMarkdown: true, type: 'other' });
}
matchEntries.forEach((entry, entryIdx) => {
const entryIdxInGroup = globalEntryIdx++;
if (entryIdxInGroup > 0) {
const innerDivider = document.createElement('div');
innerDivider.className = 'swift-match-item swift-match-divider';
innerDivider.style.cssText = 'border-top:1px solid var(--background-modifier-border);margin:4px 0;';
if (entryIdxInGroup >= 3 && !isExpanded) innerDivider.style.display = 'none';
bodyEl.appendChild(innerDivider);
}
// 圆点 + 文本内容
const itemWrapper = document.createElement('div');
itemWrapper.className = 'swift-match-item';
itemWrapper.style.cssText = 'display:flex;align-items:flex-start;gap:6px;';
if (entryIdxInGroup >= 3 && !isExpanded) itemWrapper.style.display = 'none';
const dot = document.createElement('span');
const mkData = this._multiKeywordData;
const dotBg = mkData && entry.keywordColor ? entry.keywordColor : dotColor;
dot.style.cssText = `width:8px;height:8px;border-radius:50%;background:${dotBg};flex-shrink:0;margin-top:5px;`;
itemWrapper.appendChild(dot);
const itemBox = document.createElement('div');
itemBox.style.cssText = 'flex:1;min-width:0;overflow:hidden;';
const snippetInfo = entry.snippetData;
const isTruncated = snippetInfo && typeof snippetInfo === 'object' && snippetInfo.fullLine && (snippetInfo.truncateStart > 0 || snippetInfo.truncateEnd < snippetInfo.fullLine.length);
const expandBtnStyle = 'display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;border-radius:50%;border:1px solid var(--background-modifier-border);background:transparent;color:var(--text-muted);font-size:10px;cursor:pointer;vertical-align:middle;line-height:1;padding:0;margin:0 2px;flex-shrink:0;transition:background 0.15s,color 0.15s;';
// 文本内容
const textEl = document.createElement('div');
textEl.className = 'swift-match-card-text';
textEl.style.cssText = 'font-size:12px;overflow:hidden;cursor:text;user-select:text;';
if (isTruncated) {
let currentStart = snippetInfo.truncateStart;
let currentEnd = snippetInfo.truncateEnd;
const snippetKey = `${file.path}:${item.text}:${entryIdx}`;
if (this._expandedSnippets?.[snippetKey]) {
currentStart = 0;
currentEnd = snippetInfo.fullLine.length;
}
const renderSnippet = () => {
textEl.innerHTML = '';
if (currentStart > 0) {
const leftBtn = document.createElement('button');
leftBtn.textContent = '+';
leftBtn.style.cssText = expandBtnStyle;
leftBtn.title = t('expandBefore');
leftBtn.addEventListener('click', (ce) => {
ce.preventDefault();
ce.stopPropagation();
currentStart = 0;
if (!this._expandedSnippets) this._expandedSnippets = {};
this._expandedSnippets[snippetKey] = true;
renderSnippet();
});
textEl.appendChild(leftBtn);
}
const snippetSpan = document.createElement('span');
const snippetText = snippetInfo.fullLine.slice(currentStart, currentEnd);
MarkdownRenderer.renderMarkdown(
snippetText,
snippetSpan,
file.path,
this
).then(() => {
const renderedContent = snippetSpan.querySelector('p');
if (renderedContent) {
snippetSpan.innerHTML = renderedContent.innerHTML;
}
// Re-apply highlights after markdown rendering completes
const highlightData = this._multiKeywordData;
if (highlightData) {
this._highlightKeywordsInElement(textEl, highlightData.keywords, highlightData.keywordColors);
} else if (searchText) {
const escaped = searchText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`(${escaped})`, 'gi');
this._highlightInElement(textEl, regex);
}
});
textEl.appendChild(snippetSpan);
if (currentEnd < snippetInfo.fullLine.length) {
const rightBtn = document.createElement('button');
rightBtn.textContent = '+';
rightBtn.style.cssText = expandBtnStyle;
rightBtn.title = t('expandAfter');
rightBtn.addEventListener('click', (ce) => {
ce.preventDefault();
ce.stopPropagation();
currentEnd = snippetInfo.fullLine.length;
if (!this._expandedSnippets) this._expandedSnippets = {};
this._expandedSnippets[snippetKey] = true;
renderSnippet();
});
textEl.appendChild(rightBtn);
}
};
renderSnippet();
} else if (entry.isMarkdown && entry.text) {
MarkdownRenderer.renderMarkdown(
entry.text,
textEl,
file.path,
this
).then(() => {
const renderedContent = textEl.querySelector('p');
if (renderedContent) {
textEl.innerHTML = renderedContent.innerHTML;
}
});
} else {
textEl.textContent = entry.text;
}
// Multi-keyword highlight in rendered text
if (mkData) {
const applyMkHighlight = () => {
this._highlightKeywordsInElement(textEl, mkData.keywords, mkData.keywordColors);
};
if (entry.isMarkdown || isTruncated) {
setTimeout(applyMkHighlight, 50);
} else {
applyMkHighlight();
}
}
itemBox.appendChild(textEl);
// 底部工具栏:复制 + 打开文档
const toolBar = document.createElement('div');
toolBar.style.cssText = 'display:flex;align-items:center;justify-content:flex-end;gap:4px;margin-top:2px;';
// 复制按钮
const copyBtn = document.createElement('button');
copyBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
copyBtn.title = t('copy');
copyBtn.style.cssText = 'padding:1px 4px;cursor:pointer;border:none;box-shadow:0 0 0 0.5px var(--background-modifier-border);border-radius:4px;background:var(--background-primary);color:var(--text-muted);display:flex;align-items:center;justify-content:center;height:18px;line-height:0;';
copyBtn.addEventListener('click', (ce) => {
ce.preventDefault();
ce.stopPropagation();
const textToCopy = entry.text || '';
navigator.clipboard.writeText(textToCopy).then(() => {
copyBtn.style.color = 'var(--text-normal)';
setTimeout(() => { copyBtn.style.color = 'var(--text-muted)'; }, 800);
});
});
copyBtn.addEventListener('mousedown', (e) => { e.stopPropagation(); this.isInteractingWithList = true; });
toolBar.appendChild(copyBtn);
// 打开文档按钮
const openBtn = document.createElement('button');
openBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><circle cx="16.5" cy="16.5" r="2.5"/><path d="M18.5 18.5L21 21"/></svg>';
openBtn.title = t('openDoc');
openBtn.style.cssText = 'padding:1px 4px;cursor:pointer;border:none;box-shadow:0 0 0 0.5px var(--background-modifier-border);border-radius:4px;background:var(--background-primary);color:var(--text-muted);display:flex;align-items:center;justify-content:center;height:18px;line-height:0;';
openBtn.addEventListener('click', async (ce) => {
ce.preventDefault();
ce.stopPropagation();
const selectedText = textEl && window.getSelection() && textEl.contains(window.getSelection().anchorNode) ? window.getSelection().toString().trim() : '';
this.closePreview();
this.hideMatchList();
const jumpSearchText = selectedText || searchText;
if (item.level > 0 && item.text) {
const leaf = await this.openFileInNewTabWithHeading(file, item.text, true);
setTimeout(() => {
this.app.workspace.setActiveLeaf(leaf, { focus: true });
if (selectedText) this.jumpToSearchTextInEditor(selectedText);
}, 1250);
} else if (item.type === 'tag') {
const leaf = await this.openFileInNewTab(file, true);
setTimeout(() => {
this.app.workspace.setActiveLeaf(leaf, { focus: true });
this.jumpToTagInEditor(item.text);
this._releaseRememberCursor(file);
}, 1250);
} else {
const firstSentence = this.getFirstSentence(entry.text);
const jumpText = selectedText || firstSentence || searchText;
const leaf = await this.openFileInNewTab(file, true);
setTimeout(() => {
this.app.workspace.setActiveLeaf(leaf, { focus: true });
if (jumpText) this.jumpToSearchTextInEditor(jumpText);
this._releaseRememberCursor(file);
}, 1250);
}
});
openBtn.addEventListener('mousedown', (e) => { e.stopPropagation(); this.isInteractingWithList = true; });
toolBar.appendChild(openBtn);
itemBox.appendChild(toolBar);
itemWrapper.appendChild(itemBox);
bodyEl.appendChild(itemWrapper);
}); // end matchEntries.forEach
}); // end items.forEach
if (totalEntries > 3) {
const moreLine = document.createElement('div');
moreLine.className = 'swift-match-item swift-match-more-line';
moreLine.style.cssText = `display:flex;align-items:center;gap:6px;margin-top:4px;cursor:pointer;`;
if (isExpanded) moreLine.style.display = 'none';
const moreDot = document.createElement('span');
moreDot.style.cssText = `width:8px;height:8px;border-radius:50%;background:${dotColor};flex-shrink:0;opacity:0.5;`;
moreLine.appendChild(moreDot);
const moreText = document.createElement('span');
moreText.style.cssText = 'font-size:11px;color:var(--text-faint);';
moreText.textContent = `+${totalEntries - 3} ...`;
moreLine.appendChild(moreText);
moreLine.addEventListener('click', (e) => {
e.stopPropagation();
if (!this._expandedGroups) this._expandedGroups = {};
this._expandedGroups[file.path] = true;
expandIcon.textContent = '▼';
const allItems = bodyEl.querySelectorAll('.swift-match-item:not(.swift-match-more-line):not(.swift-match-divider)');
allItems.forEach((el) => {
el.style.display = 'flex';
});
const allDividers = bodyEl.querySelectorAll('.swift-match-divider');
allDividers.forEach((el) => {
el.style.display = 'block';
});
moreLine.style.display = 'none';
});
bodyEl.appendChild(moreLine);
}
groupEl.appendChild(bodyEl);
listContainer.appendChild(groupEl);
globalFileIndex++;
});
if (this._isListVisible) {
this.matchList.style.display = 'block';
this.matchList.style.opacity = this.listOpacity;
}
if (this.floatingSearchBox) {
setTimeout(() => { this.floatingSearchBox.focus(); }, 50);
}
// Apply saved match list size
const savedWidth = this.matchListSize.width || this.settings.matchListWidth;
const maxSavedHeight = this.matchListSize.height || this.settings.matchListHeight;
this.matchList.style.width = `${savedWidth}px`;
this.matchList.style.maxWidth = `${savedWidth}px`;
// Update container max-height to fit within the list
const sizeHeader = this.matchList.querySelector('.minimap-match-list-header');
const sizeListContainer = this.matchList.querySelector('.minimap-match-list-container');
if (sizeListContainer) {
const headerHeight = sizeHeader ? sizeHeader.offsetHeight : 36;
const recentSection = this.matchList.querySelector('.minimap-match-list-recent');
const recentHeight = recentSection ? recentSection.offsetHeight : 0;
const searchRow = this.matchList.querySelector('.minimap-floating-search-box')?.parentElement;
const searchRowHeight = searchRow ? searchRow.offsetHeight : 0;
const containerMaxHeight = maxSavedHeight - headerHeight - recentHeight - 40;
sizeListContainer.style.maxHeight = `${Math.max(50, containerMaxHeight)}px`;
const termScrollPos = searchText ? (this._listScrollPositions[searchText] || 0) : 0;
const scrollTopToRestore = this._savedListScrollTop || termScrollPos || this._matchListScrollTop;
if (scrollTopToRestore) {
this._restoreListScrollTop(sizeListContainer, scrollTopToRestore);
this._savedListScrollTop = 0;
this._matchListScrollTop = 0;
}
requestAnimationFrame(() => {
const contentHeight = sizeListContainer.scrollHeight;
const autoHeight = Math.min(contentHeight + headerHeight + recentHeight + 40, maxSavedHeight);
this.matchList.style.height = `${autoHeight}px`;
});
}
// Ensure resize handle exists
if (!this.matchList.querySelector('.minimap-match-list-resize-handle')) {
const resizeHandle = document.createElement('div');
resizeHandle.className = 'minimap-match-list-resize-handle';
this.matchList.appendChild(resizeHandle);
resizeHandle.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
this.isResizingMatchList = true;
this.isInteractingWithList = true;
this.resizeStartX = e.clientX;
this.resizeStartY = e.clientY;
this.resizeStartWidth = this.matchList.offsetWidth;
this.resizeStartHeight = this.matchList.offsetHeight;
});
}
// Auto-trigger exhaustive search if mode is on and not already exhaustive
if (this.settings.exhaustiveMode && searchText && !this._exhaustiveSearchDone) {
this._exhaustiveSearchDone = true;
const headerTextEl = this.matchList.querySelector('.minimap-match-list-header-text');
setTimeout(() => {
this.performExhaustiveSearch(searchText, matchCount, headerTextEl);
}, 0);
}
if (!append && !this.isPreviewOpen) {
// Position near trigger element (keyword btn) if available, otherwise near floating toggle
if (this._listTriggerElement && this._listTriggerElement.parentNode) {
this.positionListNearElement(this._listTriggerElement);
} else {
this.positionListNearFloatingToggle();
}
if (this._matchListPositionUpdater) {
window.removeEventListener('resize', this._matchListPositionUpdater);
}
this._matchListPositionUpdater = () => {
if (this.matchList && this.matchList.style.display !== 'none' && !this.isPreviewOpen) {
if (this._listTriggerElement && this._listTriggerElement.parentNode) {
this.positionListNearElement(this._listTriggerElement);
} else {
this.positionListNearFloatingToggle();
}
}
};
window.addEventListener('resize', this._matchListPositionUpdater);
} else if (this.isPreviewOpen) {
if (this.listFixedPosition.left !== null && this.listFixedPosition.top !== null) {
this.matchList.style.left = `${this.listFixedPosition.left}px`;
this.matchList.style.top = `${this.listFixedPosition.top}px`;
}
}
if (searchText) {
setTimeout(() => { this._highlightSearchTermInList(searchText); }, 100);
}
}
_highlightSearchTermInList(searchText) {
if (!this.matchList || !searchText) return;
const escaped = searchText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`(${escaped})`, 'gi');
const textEls = this.matchList.querySelectorAll('.swift-match-card-text');
textEls.forEach(el => {
this._highlightInElement(el, regex);
});
}
_restoreListScrollTop(container, targetScrollTop, retries = 5) {
if (!container || !container.isConnected) return;
const tryRestore = (attempt) => {
if (attempt <= 0) return;
requestAnimationFrame(() => {
if (!container.isConnected) return;
container.scrollTop = targetScrollTop;
// Verify scrollTop was actually set (may fail if content not yet rendered)
if (Math.abs(container.scrollTop - targetScrollTop) > 5 && attempt > 1) {
setTimeout(() => tryRestore(attempt - 1), 100);
}
});
};
tryRestore(retries);
}
_highlightInElement(el, regex) {
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null, false);
const textNodes = [];
let node;
while (node = walker.nextNode()) {
if (regex.test(node.textContent)) {
textNodes.push(node);
}
regex.lastIndex = 0;
}
for (const textNode of textNodes) {
const text = textNode.textContent;
const parts = text.split(regex);
if (parts.length <= 1) continue;
const frag = document.createDocumentFragment();
for (const part of parts) {
if (regex.test(part)) {
const mark = document.createElement('mark');
mark.textContent = part;
mark.style.cssText = 'background:#ff660044;color:inherit;border-radius:2px;padding:0 1px;';
frag.appendChild(mark);
} else {
frag.appendChild(document.createTextNode(part));
}
regex.lastIndex = 0;
}
textNode.parentNode.replaceChild(frag, textNode);
}
}
_suppressRememberCursor(file) {
const rcp = this.app.plugins.plugins['obsidian-remember-cursor-position'];
if (rcp && rcp.loadingFile !== undefined) {
rcp.loadingFile = true;
if (file) rcp.lastLoadedFileName = file.path;
}
}
_releaseRememberCursor(file) {
const rcp = this.app.plugins.plugins['obsidian-remember-cursor-position'];
if (rcp && rcp.loadingFile !== undefined) {
if (file) {
const st = rcp.db[file.path];
if (st) rcp.lastEphemeralState = st;
}
rcp.loadingFile = false;
}
}
async openFileInNewTabWithHeading(file, heading, setActive = true) {
this._suppressRememberCursor(file);
const currentLeaf = this.app.workspace.activeLeaf;
const leaf = this.app.workspace.getLeaf('tab');
await leaf.openFile(file, { setActive });
setTimeout(() => {
this.app.workspace.setActiveLeaf(leaf, { focus: true });
this.jumpToHeadingInEditor(heading);
this._releaseRememberCursor(file);
}, 1250);
if (!setActive && currentLeaf) {
this.app.workspace.setActiveLeaf(currentLeaf, { focus: true });
}
return leaf;
}
async showPreviewWithHeading(file, heading) {
if (!this.previewPanel) return;
this.cancelSearch();
const wasPreviewOpen = this.isPreviewOpen;
this.isPreviewOpen = true;
this.currentPreviewFile = file;
this.currentPreviewHeading = heading;
this.previewSourceLeaf = this.app.workspace.activeLeaf;
if (!wasPreviewOpen) {
if (this.previewListPosition.left !== null && this.previewListPosition.top !== null) {
this.listFixedPosition.left = this.previewListPosition.left;
this.listFixedPosition.top = this.previewListPosition.top;
this.matchList.style.left = `${this.previewListPosition.left}px`;
this.matchList.style.top = `${this.previewListPosition.top}px`;
} else if (this.listFixedPosition.left !== null && this.listFixedPosition.top !== null) {
this.matchList.style.left = `${this.listFixedPosition.left}px`;
this.matchList.style.top = `${this.listFixedPosition.top}px`;
}
}
if (this._matchListPositionUpdater) {
window.removeEventListener('resize', this._matchListPositionUpdater);
this._matchListPositionUpdater = null;
}
if (this._previewOutsideClickHandler) {
document.removeEventListener('mousedown', this._previewOutsideClickHandler);
this._previewOutsideClickHandler = null;
}
try {
const content = await this.app.vault.read(file);
this.previewPanel.innerHTML = '';
const header = document.createElement('div');
header.className = 'minimap-preview-header';
header.innerHTML = `
<span class="minimap-preview-title">${file.basename}</span>
`;
header.style.cursor = 'move';
header.style.userSelect = 'none';
let isDraggingPanel = false;
let panelDragStartX, panelDragStartY, panelDragStartLeft, panelDragStartTop;
const onPanelDragMouseDown = (e) => {
if (e.target.closest('.minimap-preview-resize-handle')) return;
isDraggingPanel = true;
panelDragStartX = e.clientX;
panelDragStartY = e.clientY;
const panelRect = this.previewPanel.getBoundingClientRect();
panelDragStartLeft = panelRect.left;
panelDragStartTop = panelRect.top;
document.addEventListener('mousemove', onPanelDragMouseMove);
document.addEventListener('mouseup', onPanelDragMouseUp);
e.preventDefault();
e.stopPropagation();
};
const onPanelDragMouseMove = (e) => {
if (!isDraggingPanel) return;
const dx = e.clientX - panelDragStartX;
const dy = e.clientY - panelDragStartY;
let newLeft = panelDragStartLeft + dx;
let newTop = panelDragStartTop + dy;
newLeft = Math.max(0, Math.min(newLeft, window.innerWidth - 100));
newTop = Math.max(0, Math.min(newTop, window.innerHeight - 50));
this.previewPanel.style.left = `${newLeft}px`;
this.previewPanel.style.top = `${newTop}px`;
};
const onPanelDragMouseUp = () => {
isDraggingPanel = false;
document.removeEventListener('mousemove', onPanelDragMouseMove);
document.removeEventListener('mouseup', onPanelDragMouseUp);
};
header.addEventListener('mousedown', onPanelDragMouseDown);
const contentDiv = document.createElement('div');
contentDiv.className = 'minimap-preview-content markdown-preview-view';
const resizeHandle = document.createElement('div');
resizeHandle.className = 'minimap-preview-resize-handle';
resizeHandle.style.cssText = `
position: absolute;
right: 0;
bottom: 0;
width: 15px;
height: 15px;
cursor: nwse-resize;
z-index: 10;
background: linear-gradient(135deg, transparent 50%, var(--text-muted) 50%);
`;
let isResizing = false;
let resizeStartX, resizeStartY, resizeStartWidth, resizeStartHeight;
const onResizeMouseDown = (e) => {
isResizing = true;
resizeStartX = e.clientX;
resizeStartY = e.clientY;
resizeStartWidth = this.previewPanel.offsetWidth;
resizeStartHeight = this.previewPanel.offsetHeight;
document.addEventListener('mousemove', onResizeMouseMove);
document.addEventListener('mouseup', onResizeMouseUp);
e.preventDefault();
e.stopPropagation();
};
const onResizeMouseMove = (e) => {
if (!isResizing) return;
const dx = e.clientX - resizeStartX;
const dy = e.clientY - resizeStartY;
const newWidth = Math.max(300, Math.min(resizeStartWidth + dx, window.innerWidth - 20));
const newHeight = Math.max(200, Math.min(resizeStartHeight + dy, window.innerHeight - 20));
this.previewPanel.style.width = `${newWidth}px`;
this.previewPanel.style.height = `${newHeight}px`;
this.previewSize.width = newWidth;
this.previewSize.height = newHeight;
};
const onResizeMouseUp = () => {
isResizing = false;
document.removeEventListener('mousemove', onResizeMouseMove);
document.removeEventListener('mouseup', onResizeMouseUp);
this.saveListData();
};
resizeHandle.addEventListener('mousedown', onResizeMouseDown);
this.previewPanel.appendChild(header);
this.previewPanel.appendChild(contentDiv);
this.previewPanel.appendChild(resizeHandle);
this.previewPanel.style.display = 'flex';
try {
await MarkdownRenderer.render(this.app, content, contentDiv, file.path, this);
if (!contentDiv.innerHTML || contentDiv.innerHTML.trim() === '') {
contentDiv.innerHTML = `<pre style="white-space: pre-wrap; margin: 0;">${content.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</pre>`;
}
} catch (renderError) {
console.error('MarkdownRenderer.render failed:', renderError);
contentDiv.innerHTML = `<pre style="white-space: pre-wrap; margin: 0;">${content.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</pre>`;
}
this.positionPreviewPanel();
this.highlightCurrentPreviewItem();
this.setupPreviewOutsideClick();
setTimeout(() => {
this.scrollToHeadingInPreview(contentDiv, heading);
}, 100);
} catch (e) {
console.error('Failed to read file:', e);
this.closePreview();
}
}
scrollToHeadingInPreview(contentDiv, heading) {
if (!contentDiv) return;
const headingText = heading.toLowerCase().trim();
const elements = contentDiv.querySelectorAll('h1, h2, h3, h4, h5, h6');
for (const el of elements) {
const elText = el.textContent.toLowerCase().trim();
const cleanElText = elText.replace(/<[^>]*>/g, '').trim();
if (cleanElText === headingText || cleanElText.includes(headingText)) {
el.scrollIntoView({ behavior: 'auto', block: 'start' });
break;
}
}
}
jumpToHeadingInEditor(heading) {
if (this.isReadingMode()) {
const readingEl = this.getReadingViewContentEl();
if (!readingEl) return;
const headingText = heading.toLowerCase().trim();
const elements = readingEl.querySelectorAll('h1, h2, h3, h4, h5, h6');
for (const el of elements) {
const elText = el.textContent.toLowerCase().trim();
if (elText === headingText || elText.includes(headingText)) {
el.scrollIntoView({ behavior: 'auto', block: 'start' });
break;
}
}
return;
}
const editor = this.getEditor();
if (!editor || !editor.cm) return;
const cm = editor.cm;
const content = editor.getValue();
const lines = content.split('\n');
const headingLower = heading.toLowerCase().trim();
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
if (headingMatch) {
const lineHeadingText = headingMatch[2].replace(/\s+#[\w\u4e00-\u9fa5]+\s*$/g, '').replace(/<[^>]*>/g, '').trim().toLowerCase();
if (lineHeadingText === headingLower || lineHeadingText.includes(headingLower)) {
const lineNum = i + 1;
const lineObj = cm.state.doc.line(lineNum);
cm.dispatch({
selection: { anchor: lineObj.from },
scrollIntoView: { range: { from: lineObj.from, to: lineObj.from } }
});
break;
}
}
}
}
positionListNearFloatingToggle() {
if (!this.matchList || !this.floatingToggle) return;
const toggleRect = this.floatingToggle.getBoundingClientRect();
const listWidth = this.matchListSize.width || this.settings.matchListWidth || 280;
const listHeight = this.matchList.offsetHeight || (this.matchListSize.height || this.settings.matchListHeight || 400);
let left = toggleRect.left;
let top = toggleRect.bottom + 5;
// If list would go off right edge, align to right
if (left + listWidth > window.innerWidth - 10) {
left = window.innerWidth - listWidth - 10;
}
// If list would go off left edge
if (left < 10) left = 10;
if (top + listHeight > window.innerHeight - 10) {
const availableBelow = window.innerHeight - top - 10;
const availableAbove = toggleRect.top - 15;
if (availableBelow >= 60) {
this.matchList.style.maxHeight = `${availableBelow}px`;
} else if (availableAbove >= 60) {
top = toggleRect.top - Math.min(listHeight, availableAbove) - 5;
this.matchList.style.maxHeight = `${Math.min(listHeight, availableAbove)}px`;
} else {
this.matchList.style.maxHeight = `${Math.max(60, window.innerHeight - 20)}px`;
top = 10;
}
} else {
this.matchList.style.maxHeight = '';
}
this.matchList.style.left = `${left}px`;
this.matchList.style.top = `${top}px`;
}
positionListNearIndicator() {
if (!this.matchList || !this.lastIndicatorCoords) {
this.positionListDefault();
return;
}
const listWidth = this.matchListSize.width || this.settings.matchListWidth || 280;
const listHeight = this.matchListSize.height || this.settings.matchListHeight || 400;
const targetRect = this.lastIndicatorCoords.targetRect;
const indicatorX = this.lastIndicatorCoords.screenX;
const indicatorY = this.lastIndicatorCoords.screenY;
const selectionRight = targetRect ? targetRect.right + 20 : indicatorX;
const selectionLeft = targetRect ? targetRect.left - 20 : indicatorX;
const selectionTop = targetRect ? targetRect.top : indicatorY;
const selectionBottom = targetRect ? targetRect.bottom : indicatorY;
const selectionCenter = (selectionTop + selectionBottom) / 2;
const availableWidthRight = window.innerWidth - selectionRight - 10;
const availableWidthLeft = selectionLeft - 10;
let left, top;
if (availableWidthRight >= listWidth) {
left = selectionRight;
} else if (availableWidthLeft >= listWidth) {
left = selectionLeft - listWidth;
} else if (availableWidthRight >= availableWidthLeft) {
left = window.innerWidth - listWidth - 10;
} else {
left = 10;
}
let idealTop = selectionCenter - listHeight / 2;
if (idealTop < 10) {
idealTop = 10;
} else if (idealTop + listHeight > window.innerHeight - 10) {
idealTop = window.innerHeight - listHeight - 10;
}
top = idealTop;
this.matchList.style.left = `${left}px`;
this.matchList.style.top = `${top}px`;
}
updateListPositionNearSelection() {
if (!this.matchList || this.matchList.style.display === 'none') return;
if (this.isPreviewOpen) return;
if (this.listFixedPosition.left !== null || this.listFixedPosition.top !== null) return;
const listWidth = this.matchListSize.width || this.settings.matchListWidth || 280;
const listHeight = this.matchListSize.height || this.settings.matchListHeight || 400;
let selectionRight, selectionLeft, selectionTop, selectionBottom;
if (this.isReadingMode()) {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0 || sel.isCollapsed) {
this.positionListDefault();
return;
}
try {
const range = sel.getRangeAt(0);
const rect = range.getBoundingClientRect();
if (!rect || rect.width === 0) {
this.positionListDefault();
return;
}
selectionRight = rect.right + 20;
selectionLeft = rect.left - 20;
selectionTop = rect.top;
selectionBottom = rect.bottom;
} catch (e) {
this.positionListDefault();
return;
}
} else {
const editor = this.getEditor();
if (!editor || !editor.cm) {
this.positionListDefault();
return;
}
const cm = editor.cm;
const selection = editor.getSelection();
if (!selection) {
this.positionListDefault();
return;
}
try {
const from = editor.getCursor('from');
const to = editor.getCursor('to');
const lineFrom = cm.state.doc.line(from.line + 1);
const lineTo = cm.state.doc.line(to.line + 1);
const posFrom = lineFrom.from + from.ch;
const posTo = lineTo.from + to.ch;
const coordsFrom = cm.coordsAtPos(posFrom);
const coordsTo = cm.coordsAtPos(posTo);
if (!coordsFrom || !coordsTo) {
this.positionListDefault();
return;
}
selectionRight = Math.max(coordsFrom.left, coordsTo.left) + 20;
selectionLeft = Math.min(coordsFrom.left, coordsTo.left) - 20;
selectionTop = Math.min(coordsFrom.top, coordsTo.top);
selectionBottom = Math.max(coordsFrom.bottom, coordsTo.bottom);
} catch (e) {
this.positionListDefault();
return;
}
}
const availableWidthRight = window.innerWidth - selectionRight - 10;
const availableWidthLeft = selectionLeft - 10;
let left, top;
if (availableWidthRight >= listWidth) {
left = selectionRight;
} else if (availableWidthLeft >= listWidth) {
left = selectionLeft - listWidth;
} else if (availableWidthRight >= availableWidthLeft) {
left = window.innerWidth - listWidth - 10;
} else {
left = 10;
}
const selectionCenter = (selectionTop + selectionBottom) / 2;
let idealTop = selectionCenter - listHeight / 2;
if (idealTop < 10) {
idealTop = 10;
} else if (idealTop + listHeight > window.innerHeight - 10) {
idealTop = window.innerHeight - listHeight - 10;
}
top = idealTop;
this.matchList.style.left = `${left}px`;
this.matchList.style.top = `${top}px`;
}
positionListDefault() {
if (!this.matchList || !this.minimapContainer) return;
const rect = this.minimapContainer.getBoundingClientRect();
const listWidth = this.matchListSize.width || this.settings.matchListWidth || 280;
const listHeight = this.matchListSize.height || this.settings.matchListHeight || 400;
let left = rect.left - listWidth - 10;
let top = rect.top;
if (left < 10) {
left = rect.right + 10;
}
if (top + listHeight > window.innerHeight - 10) {
top = Math.max(10, window.innerHeight - listHeight - 10);
}
this.matchList.style.left = `${left}px`;
this.matchList.style.top = `${top}px`;
}
async openFile(file) {
const leaf = this.app.workspace.getLeaf();
await leaf.openFile(file);
}
async openFileInNewTab(file, setActive = true) {
this._suppressRememberCursor(file);
const leaf = this.app.workspace.getLeaf('tab');
await leaf.openFile(file, { setActive });
return leaf;
}
// 从文本中提取第一句话(支持中英文句号、感叹号、问号、换行)
getFirstSentence(text) {
if (!text) return '';
// Remove leading "..." if present (from snippet truncation)
let cleanText = text.replace(/^\.\.\.\s*/, '');
const match = cleanText.match(/[^。!?\.\?\!]*[。!?\.\?\!\n]/);
if (match) {
return match[0].trim();
}
// 如果没有找到句末标点,返回整段文本
return cleanText.trim();
}
// 清空跳转高亮
clearJumpHighlight(cm) {
if (!cm) return;
cm.dispatch({
effects: setJumpHighlight.of(Decoration.none)
});
}
// 编辑模式:直接向 matchHighlightField 添加棉花糖装饰
highlightJumpText(cm, from, to) {
if (!cm) return;
this._ensureEditorFields(cm);
const existing = cm.state.field(matchHighlightField, false) || Decoration.none;
const marshmallowDeco = Decoration.mark({
class: 'minimap-editor-match hl-marshmallow',
attributes: {
'style': `background:#fff !important;color:#cc2e7a !important;border:2px solid #ff6eb4 !important;border-radius:10px !important;padding:0.1em 0.6em !important;box-shadow:2px 2px 0 #ffaad9 !important;margin:0 -0.2em;-webkit-box-decoration-break:clone;box-decoration-break:clone;`
}
});
const newSet = existing.update({
add: [{ from, to, value: marshmallowDeco }]
});
cm.dispatch({
effects: setMatchHighlights.of(newSet)
});
this._jumpHighlightRange = { from, to };
this._setupMarshmallowDismiss(cm);
}
_setupMarshmallowDismiss(cm) {
if (this._marshmallowDismissHandler) {
cm.dom.removeEventListener('mousedown', this._marshmallowDismissHandler);
}
this._marshmallowDismissHandler = () => {
this._jumpHighlightRange = null;
this._removeMarshmallowDeco(cm);
cm.dom.removeEventListener('mousedown', this._marshmallowDismissHandler);
this._marshmallowDismissHandler = null;
};
cm.dom.addEventListener('mousedown', this._marshmallowDismissHandler);
}
_removeMarshmallowDeco(cm) {
if (!cm) return;
const current = cm.state.field(matchHighlightField, false);
if (current) {
const filtered = current.update({
filter: (f, t, v) => {
const spec = v.spec;
return !(spec && spec.class && spec.class.includes('hl-marshmallow'));
}
});
cm.dispatch({
effects: setMatchHighlights.of(filtered)
});
}
}
_ensureEditorFields(cm) {
if (!cm) return;
const matchField = cm.state.field(matchHighlightField, false);
if (matchField === undefined) {
cm.dispatch({
effects: StateEffect.appendConfig.of([matchHighlightField])
});
}
const jumpField = cm.state.field(jumpHighlightField, false);
if (jumpField === undefined) {
cm.dispatch({
effects: StateEffect.appendConfig.of([jumpHighlightField])
});
}
}
// 阅读模式:用 span.hl-marshmallow 包裹跳转文本
highlightJumpTextReading(container, textNode, offset, length) {
const parent = textNode.parentNode;
if (!parent || parent.closest('.hl-marshmallow')) return;
const range = document.createRange();
range.setStart(textNode, offset);
range.setEnd(textNode, offset + length);
const span = document.createElement('span');
span.className = 'hl-marshmallow';
span.style.cssText = 'background:#fff !important;color:#cc2e7a !important;border:2px solid #ff6eb4 !important;border-radius:10px !important;padding:0.1em 0.6em !important;box-shadow:2px 2px 0 #ffaad9 !important;';
range.surroundContents(span);
// 3秒后自动移除
setTimeout(() => {
const parentSpan = span.parentNode;
if (parentSpan) {
const text = span.textContent || '';
const textNode2 = document.createTextNode(text);
parentSpan.replaceChild(textNode2, span);
}
}, 3000);
}
jumpToSearchTextInEditor(searchText) {
if (this.isReadingMode()) {
const readingEl = this.getReadingViewContentEl();
if (!readingEl) return;
const searchLower = searchText.toLowerCase();
const walker = document.createTreeWalker(readingEl, NodeFilter.SHOW_TEXT, null, false);
while (walker.nextNode()) {
const node = walker.currentNode;
const idx = node.textContent.toLowerCase().indexOf(searchLower);
if (idx !== -1) {
const range = document.createRange();
range.setStart(node, idx);
range.setEnd(node, idx + searchText.length);
range.scrollIntoView({ behavior: 'auto', block: 'center' });
// 临时高亮 hl-marshmallow
this.highlightJumpTextReading(readingEl, node, idx, searchText.length);
break;
}
}
return;
}
const editor = this.getEditor();
if (!editor || !editor.cm) return;
const cm = editor.cm;
const content = editor.getValue();
const searchLower = searchText.toLowerCase();
const fullIdx = content.toLowerCase().indexOf(searchLower);
if (fullIdx !== -1) {
const from = fullIdx;
const to = fullIdx + searchText.length;
cm.dispatch({
selection: { anchor: from },
scrollIntoView: { range: { from, to } }
});
setTimeout(() => {
const coords = cm.coordsAtPos(from);
const scrollEl = cm.scrollDOM;
if (coords && scrollEl) {
const visibleHeight = scrollEl.clientHeight;
const targetY = coords.top - scrollEl.getBoundingClientRect().top + scrollEl.scrollTop - visibleHeight * 0.35;
scrollEl.scrollTop = targetY;
}
}, 50);
this.highlightJumpText(cm, from, to);
return;
}
const fallbackText = this.currentSelection;
if (fallbackText && fallbackText !== searchText) {
const fallbackIdx = content.toLowerCase().indexOf(fallbackText.toLowerCase());
if (fallbackIdx !== -1) {
const from = fallbackIdx;
const to = fallbackIdx + fallbackText.length;
cm.dispatch({
selection: { anchor: from },
scrollIntoView: { range: { from, to } }
});
setTimeout(() => {
const coords = cm.coordsAtPos(from);
const scrollEl = cm.scrollDOM;
if (coords && scrollEl) {
const visibleHeight = scrollEl.clientHeight;
const targetY = coords.top - scrollEl.getBoundingClientRect().top + scrollEl.scrollTop - visibleHeight * 0.35;
scrollEl.scrollTop = targetY;
}
}, 50);
this.highlightJumpText(cm, from, to);
}
}
}
jumpToTagInEditor(tag) {
if (this.isReadingMode()) {
const readingEl = this.getReadingViewContentEl();
if (!readingEl) return;
const tagSelector = `a.tag[href*="${tag}"], .tag[href*="${tag}"]`;
const tagEl = readingEl.querySelector(tagSelector);
if (tagEl) {
tagEl.scrollIntoView({ behavior: 'auto', block: 'center' });
} else {
// Fallback: search text nodes for the tag
const searchLower = `#${tag.toLowerCase()}`;
const walker = document.createTreeWalker(readingEl, NodeFilter.SHOW_TEXT, null, false);
while (walker.nextNode()) {
const node = walker.currentNode;
const idx = node.textContent.toLowerCase().indexOf(searchLower);
if (idx !== -1) {
const range = document.createRange();
range.setStart(node, idx);
range.setEnd(node, idx + searchLower.length);
range.scrollIntoView({ behavior: 'auto', block: 'center' });
break;
}
}
}
return;
}
const editor = this.getEditor();
if (!editor || !editor.cm) return;
const cm = editor.cm;
const content = editor.getValue();
const lines = content.split('\n');
const tagPattern = new RegExp(`#${tag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?=[\\s,.;:!?'")\\]}>]|$)`, 'i');
for (let i = 0; i < lines.length; i++) {
const match = tagPattern.exec(lines[i]);
if (match) {
const lineNum = i + 1;
const lineObj = cm.state.doc.line(lineNum);
const from = lineObj.from + match.index;
cm.dispatch({
selection: { anchor: from },
scrollIntoView: { range: { from, to: from } }
});
break;
}
}
}
async showPreview(file) {
if (!this.previewPanel) return;
this.cancelSearch();
this.isPreviewOpen = true;
this.currentPreviewFile = file;
if (this.previewListPosition.left !== null && this.previewListPosition.top !== null) {
this.listFixedPosition.left = this.previewListPosition.left;
this.listFixedPosition.top = this.previewListPosition.top;
this.matchList.style.left = `${this.previewListPosition.left}px`;
this.matchList.style.top = `${this.previewListPosition.top}px`;
}
if (this._matchListPositionUpdater) {
window.removeEventListener('resize', this._matchListPositionUpdater);
this._matchListPositionUpdater = null;
}
try {
const content = await this.app.vault.read(file);
this.previewPanel.innerHTML = '';
const header = document.createElement('div');
header.className = 'minimap-preview-header';
header.innerHTML = `
<span class="minimap-preview-title">${file.basename}</span>
`;
header.style.cursor = 'move';
header.style.userSelect = 'none';
let isDraggingPanel = false;
let panelDragStartX, panelDragStartY, panelDragStartLeft, panelDragStartTop;
const onPanelDragMouseDown = (e) => {
if (e.target.closest('.minimap-preview-resize-handle')) return;
isDraggingPanel = true;
panelDragStartX = e.clientX;
panelDragStartY = e.clientY;
const panelRect = this.previewPanel.getBoundingClientRect();
panelDragStartLeft = panelRect.left;
panelDragStartTop = panelRect.top;
document.addEventListener('mousemove', onPanelDragMouseMove);
document.addEventListener('mouseup', onPanelDragMouseUp);
e.preventDefault();
e.stopPropagation();
};
const onPanelDragMouseMove = (e) => {
if (!isDraggingPanel) return;
const dx = e.clientX - panelDragStartX;
const dy = e.clientY - panelDragStartY;
let newLeft = panelDragStartLeft + dx;
let newTop = panelDragStartTop + dy;
newLeft = Math.max(0, Math.min(newLeft, window.innerWidth - 100));
newTop = Math.max(0, Math.min(newTop, window.innerHeight - 50));
this.previewPanel.style.left = `${newLeft}px`;
this.previewPanel.style.top = `${newTop}px`;
};
const onPanelDragMouseUp = () => {
isDraggingPanel = false;
document.removeEventListener('mousemove', onPanelDragMouseMove);
document.removeEventListener('mouseup', onPanelDragMouseUp);
};
header.addEventListener('mousedown', onPanelDragMouseDown);
const contentDiv = document.createElement('div');
contentDiv.className = 'minimap-preview-content markdown-preview-view';
const resizeHandle = document.createElement('div');
resizeHandle.className = 'minimap-preview-resize-handle';
resizeHandle.style.cssText = `
position: absolute;
right: 0;
bottom: 0;
width: 15px;
height: 15px;
cursor: nwse-resize;
z-index: 10;
background: linear-gradient(135deg, transparent 50%, var(--text-muted) 50%);
`;
let isResizing = false;
let resizeStartX, resizeStartY, resizeStartWidth, resizeStartHeight;
const onResizeMouseDown = (e) => {
isResizing = true;
resizeStartX = e.clientX;
resizeStartY = e.clientY;
resizeStartWidth = this.previewPanel.offsetWidth;
resizeStartHeight = this.previewPanel.offsetHeight;
document.addEventListener('mousemove', onResizeMouseMove);
document.addEventListener('mouseup', onResizeMouseUp);
e.preventDefault();
e.stopPropagation();
};
const onResizeMouseMove = (e) => {
if (!isResizing) return;
const dx = e.clientX - resizeStartX;
const dy = e.clientY - resizeStartY;
const newWidth = Math.max(300, Math.min(resizeStartWidth + dx, window.innerWidth - 20));
const newHeight = Math.max(200, Math.min(resizeStartHeight + dy, window.innerHeight - 20));
this.previewPanel.style.width = `${newWidth}px`;
this.previewPanel.style.height = `${newHeight}px`;
this.previewSize.width = newWidth;
this.previewSize.height = newHeight;
};
const onResizeMouseUp = () => {
isResizing = false;
document.removeEventListener('mousemove', onResizeMouseMove);
document.removeEventListener('mouseup', onResizeMouseUp);
this.saveListData();
};
resizeHandle.addEventListener('mousedown', onResizeMouseDown);
this.previewPanel.appendChild(header);
this.previewPanel.appendChild(contentDiv);
this.previewPanel.appendChild(resizeHandle);
this.previewPanel.style.display = 'flex';
try {
await MarkdownRenderer.render(this.app, content, contentDiv, file.path, this);
if (!contentDiv.innerHTML || contentDiv.innerHTML.trim() === '') {
contentDiv.innerHTML = `<pre style="white-space: pre-wrap; margin: 0;">${content.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</pre>`;
}
} catch (renderError) {
console.error('MarkdownRenderer.render failed:', renderError);
contentDiv.innerHTML = `<pre style="white-space: pre-wrap; margin: 0;">${content.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</pre>`;
}
this.positionPreviewPanel();
this.highlightCurrentPreviewItem();
this.setupPreviewOutsideClick();
} catch (e) {
console.error('Failed to read file:', e);
this.closePreview();
}
}
setupPreviewOutsideClick() {
if (this._previewOutsideClickHandler) {
document.removeEventListener('mousedown', this._previewOutsideClickHandler);
}
this._previewOutsideClickHandler = (e) => {
if (!this.previewPanel || this.previewPanel.style.display === 'none') return;
const isInPreview = this.previewPanel.contains(e.target);
const isInList = this.matchList && this.matchList.contains(e.target);
if (!isInPreview && !isInList) {
this.closePreview();
e.stopPropagation();
}
};
setTimeout(() => {
document.addEventListener('mousedown', this._previewOutsideClickHandler);
}, 10);
}
highlightCurrentPreviewItem() {
if (!this.matchList || !this.currentPreviewFile) return;
const groupItems = this.matchList.querySelectorAll('.swift-match-group');
groupItems.forEach(item => {
if (item.dataset.filePath === this.currentPreviewFile.path) {
item.classList.add('previewing');
} else {
item.classList.remove('previewing');
}
});
}
async exportMatchList(fileMap, searchText, matchCount) {
if (!fileMap || fileMap.size === 0) return;
const now = new Date();
const dateStr = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}_${String(now.getHours()).padStart(2,'0')}${String(now.getMinutes()).padStart(2,'0')}`;
const fileName = `SwiftMatch_${searchText}_${dateStr}.md`;
const totalMatchCount = this._pendingMatchCount || matchCount;
let md = `# SwiftMatch 搜索结果\n\n`;
md += `- **搜索词**: \`${searchText}\`\n`;
md += `- **匹配文档数**: ${fileMap.size}\n`;
md += `- **总匹配数**: ${totalMatchCount} 项结果\n`;
md += `- **导出时间**: ${now.toLocaleString()}\n\n`;
md += `---\n\n`;
fileMap.forEach((items, file) => {
md += `## [[${file.basename}]]\n\n`;
items.forEach(item => {
if (item.type === 'tag') {
md += `- #${item.text}\n`;
} else if (item.type === 'heading') {
md += `- **[[${file.basename}#${item.text}]]**\n`;
} else if (item.type === 'content') {
if (item.snippets && item.snippets.length > 0) {
for (const s of item.snippets) {
md += ` > ${s.replace(/\n/g, ' ')}\n`;
}
} else if (item.snippet) {
md += ` > ${item.snippet.replace(/\n/g, ' ')}\n`;
} else {
md += `- ${item.text}\n`;
}
} else {
md += `- ${item.text}\n`;
}
});
md += `\n`;
});
try {
const adapter = this.app.vault.adapter;
await adapter.write(fileName, md);
// Open the file in a new tab
const file = this.app.vault.getAbstractFileByPath(fileName);
if (file) {
await this.app.workspace.getLeaf('tab').openFile(file);
}
new Notice(t('exported', fileName));
} catch (e) {
console.error('Failed to export match list:', e);
new Notice(t('exportFailed', e.message));
}
}
_setupSwiftMatchExportNav() {
// Remove existing popup and listeners
this._removeSwiftMatchExportNav();
const file = this.app.workspace.activeLeaf?.view?.file;
if (!file || !file.basename.startsWith('SwiftMatch_')) return;
// Listen for text selection in this document
this._swiftMatchMouseUpHandler = () => {
setTimeout(() => this._handleSwiftMatchSelection(), 200);
};
this._swiftMatchMouseDownHandler = () => {
this._hideSwiftMatchNavPopup();
};
document.addEventListener('mouseup', this._swiftMatchMouseUpHandler);
document.addEventListener('mousedown', this._swiftMatchMouseDownHandler, true);
}
_removeSwiftMatchExportNav() {
if (this._swiftMatchMouseUpHandler) {
document.removeEventListener('mouseup', this._swiftMatchMouseUpHandler);
this._swiftMatchMouseUpHandler = null;
}
if (this._swiftMatchMouseDownHandler) {
document.removeEventListener('mousedown', this._swiftMatchMouseDownHandler, true);
this._swiftMatchMouseDownHandler = null;
}
this._hideSwiftMatchNavPopup();
}
_handleSwiftMatchSelection() {
const file = this.app.workspace.activeLeaf?.view?.file;
if (!file || !file.basename.startsWith('SwiftMatch_')) {
this._removeSwiftMatchExportNav();
return;
}
const sel = window.getSelection();
if (!sel || sel.isCollapsed || sel.toString().trim().length === 0) {
this._hideSwiftMatchNavPopup();
return;
}
// Find the nearest heading/wiki-link above the selection
const range = sel.getRangeAt(0);
let node = range.startContainer;
// Walk up to find the content block element
let container = node instanceof Element ? node : node.parentElement;
const contentEl = container?.closest('.markdown-reading-view, .markdown-preview-view, .markdown-source-view');
if (!contentEl) return;
// Find the closest heading or section with a wiki-link
let sourceInfo = this._findSourceInfoForSelection(container, contentEl);
if (!sourceInfo) {
this._hideSwiftMatchNavPopup();
return;
}
// Show the navigation popup
const rect = range.getBoundingClientRect();
this._showSwiftMatchNavPopup(rect, sourceInfo, sel.toString().trim());
}
_findSourceInfoForSelection(element, contentEl) {
// Walk up from the selection element to find the nearest heading/section with a wiki-link
let current = element;
let foundHeading = null;
let foundFile = null;
while (current && current !== contentEl) {
if (current.nodeType === Node.ELEMENT_NODE) {
// Check if this is a heading with a link
if (current.tagName && current.tagName.match(/^H[1-6]$/)) {
const link = current.querySelector('a.internal-link, .internal-link');
if (link) {
const href = link.getAttribute('data-href') || link.getAttribute('href') || link.textContent;
if (href) {
const parts = href.split('#');
foundFile = parts[0];
foundHeading = parts.length > 1 ? parts.slice(1).join('#') : null;
return { fileName: foundFile, heading: foundHeading };
}
}
// Also check for plain text wiki-link format in the heading
const headingText = current.textContent;
const wikiMatch = headingText.match(/\[\[([^\]]+)\]\]/);
if (wikiMatch) {
const parts = wikiMatch[1].split('#');
foundFile = parts[0];
foundHeading = parts.length > 1 ? parts.slice(1).join('#') : null;
return { fileName: foundFile, heading: foundHeading };
}
}
// Check if this element contains an internal link
const links = current.querySelectorAll(':scope > a.internal-link, :scope > .internal-link');
for (const link of links) {
const href = link.getAttribute('data-href') || link.getAttribute('href') || link.textContent;
if (href) {
const parts = href.split('#');
foundFile = parts[0];
foundHeading = parts.length > 1 ? parts.slice(1).join('#') : null;
return { fileName: foundFile, heading: foundHeading };
}
}
}
current = current.parentElement;
}
// Fallback: scan all headings in the document for the section containing the selection
const allHeadings = contentEl.querySelectorAll('h1, h2, h3, h4, h5, h6');
const selRange = window.getSelection().getRangeAt(0);
const selTop = selRange.getBoundingClientRect().top;
let closestHeading = null;
let closestTop = -Infinity;
for (const h of allHeadings) {
const hRect = h.getBoundingClientRect();
if (hRect.top <= selTop && hRect.top > closestTop) {
closestTop = hRect.top;
closestHeading = h;
}
}
if (closestHeading) {
// Look for wiki-link in heading text (format: ## [[filename]] or ## [[filename#heading]])
const headingText = closestHeading.textContent;
const wikiMatch = headingText.match(/\[\[([^\]]+)\]\]/);
if (wikiMatch) {
const parts = wikiMatch[1].split('#');
return { fileName: parts[0], heading: parts.length > 1 ? parts.slice(1).join('#') : null };
}
// Also check for rendered internal links
const link = closestHeading.querySelector('a.internal-link, .internal-link');
if (link) {
const href = link.getAttribute('data-href') || link.getAttribute('href') || link.textContent;
if (href) {
const parts = href.split('#');
return { fileName: parts[0], heading: parts.length > 1 ? parts.slice(1).join('#') : null };
}
}
}
return null;
}
_showSwiftMatchNavPopup(rect, sourceInfo, selectedText) {
this._hideSwiftMatchNavPopup();
const popup = document.createElement('div');
popup.className = 'swift-match-nav-popup';
popup.style.cssText = `
position: fixed;
z-index: 999999;
background: var(--background-primary);
border: 1px solid var(--interactive-accent);
border-radius: 6px;
padding: 4px 10px;
cursor: pointer;
font-size: 12px;
color: var(--text-normal);
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
white-space: nowrap;
transition: background 0.15s, transform 0.15s;
top: ${rect.top - 32}px;
left: ${rect.left + rect.width / 2 - 60}px;
`;
const displayText = sourceInfo.heading
? `${sourceInfo.fileName}#${sourceInfo.heading}`
: `${sourceInfo.fileName}`;
popup.textContent = displayText;
popup.addEventListener('mouseenter', () => {
popup.style.background = 'var(--background-modifier-hover)';
popup.style.transform = 'scale(1.05)';
});
popup.addEventListener('mouseleave', () => {
popup.style.background = 'var(--background-primary)';
popup.style.transform = 'scale(1)';
});
popup.addEventListener('mousedown', (e) => {
e.stopPropagation();
e.preventDefault();
});
popup.addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
this._navigateToSwiftMatchSource(sourceInfo, selectedText);
this._hideSwiftMatchNavPopup();
});
document.body.appendChild(popup);
this._swiftMatchNavPopup = popup;
// Auto-hide after 5 seconds
this._swiftMatchNavTimer = setTimeout(() => {
this._hideSwiftMatchNavPopup();
}, 5000);
}
_hideSwiftMatchNavPopup() {
if (this._swiftMatchNavTimer) {
clearTimeout(this._swiftMatchNavTimer);
this._swiftMatchNavTimer = null;
}
if (this._swiftMatchNavPopup) {
this._swiftMatchNavPopup.remove();
this._swiftMatchNavPopup = null;
}
}
async _navigateToSwiftMatchSource(sourceInfo, selectedText) {
// Find the file by name
const allFiles = this.app.vault.getMarkdownFiles();
let targetFile = null;
for (const f of allFiles) {
if (f.basename === sourceInfo.fileName) {
targetFile = f;
break;
}
}
if (!targetFile) {
new Notice(t('sourceNotFound', sourceInfo.fileName));
return;
}
// Open in new tab
const leaf = this.app.workspace.getLeaf('tab');
await leaf.openFile(targetFile);
// Wait for the file to load, then navigate to the heading or search for the selected text
setTimeout(() => {
if (sourceInfo.heading) {
// Navigate to heading
this._jumpToHeadingInActiveDoc(sourceInfo.heading);
}
// Also try to find and select the specific text
if (selectedText) {
this._findAndSelectTextInActiveDoc(selectedText);
}
}, 500);
}
_jumpToHeadingInActiveDoc(headingText) {
// Try to find the heading in the current view
const view = this.app.workspace.activeLeaf?.view;
if (!view) return;
// For reading mode
const readingView = view.containerEl?.querySelector('.markdown-reading-view, .markdown-preview-view');
if (readingView) {
const headings = readingView.querySelectorAll('h1, h2, h3, h4, h5, h6');
for (const h of headings) {
const text = h.textContent.replace(/\[\[|\]\]/g, '').trim();
if (text.includes(headingText) || headingText.includes(text)) {
h.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Highlight briefly
h.style.backgroundColor = 'rgba(255, 200, 0, 0.3)';
setTimeout(() => { h.style.backgroundColor = ''; }, 2000);
return;
}
}
}
// For source mode, use editor search
const editor = this.getEditor();
if (editor) {
const content = editor.getValue();
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
const headingMatch = lines[i].match(/^#{1,6}\s+(.+?)(?:\s+#[\w\u4e00-\u9fa5\-\/]+)*\s*$/);
if (headingMatch) {
const hText = headingMatch[1].replace(/\s+#[\w\u4e00-\u9fa5\-\/]+\s*$/g, '').trim();
if (hText.includes(headingText) || headingText.includes(hText)) {
editor.setCursor({ line: i, ch: 0 });
editor.scrollIntoView({ from: { line: i, ch: 0 }, to: { line: i, ch: 0 } }, true);
return;
}
}
}
}
}
_findAndSelectTextInActiveDoc(text) {
const editor = this.getEditor();
if (!editor) return;
const content = editor.getValue();
const searchLower = text.toLowerCase();
const contentLower = content.toLowerCase();
const pos = contentLower.indexOf(searchLower);
if (pos >= 0) {
// Convert character offset to line/ch
const beforeText = content.substring(0, pos);
const line = beforeText.split('\n').length - 1;
const ch = pos - beforeText.lastIndexOf('\n') - 1;
const endLine = line + text.split('\n').length - 1;
const lastNewline = text.lastIndexOf('\n');
const endCh = lastNewline >= 0 ? text.length - lastNewline - 1 : ch + text.length;
editor.setSelection({ line, ch: Math.max(0, ch) }, { line: endLine, ch: endCh });
editor.scrollIntoView({ from: { line, ch: 0 }, to: { line: endLine, ch: 0 } }, true);
}
}
closePreview() {
if (this._previewOutsideClickHandler) {
document.removeEventListener('mousedown', this._previewOutsideClickHandler);
this._previewOutsideClickHandler = null;
}
if (this.previewPanel) {
this.previewPanel.style.display = 'none';
this.previewPanel.innerHTML = '';
}
this.isPreviewOpen = false;
this.currentPreviewFile = null;
this.currentPreviewHeading = null;
this.restoreListPosition();
this._pendingListClose = true;
this._listCloseHandler = (e) => {
if (this._pendingListClose && this.matchList && this.matchList.style.display !== 'none') {
const clickedModal = e.target.closest('.modal-container, .modal-bg, .modal, [role="dialog"]');
const clickedKeywordBtn = e.target.closest('.swift-match-keyword-btn-wrapper');
if ((!this.matchList.contains(e.target) && !clickedKeywordBtn) || clickedModal) {
this.hideMatchList();
this._pendingListClose = false;
document.removeEventListener('mousedown', this._listCloseHandler);
}
}
};
setTimeout(() => {
document.addEventListener('mousedown', this._listCloseHandler);
}, 10);
}
restoreListPosition() {
if (this.matchList) {
const savedWidth = this.matchListSize.width || this.settings.matchListWidth;
const savedHeight = this.matchListSize.height || this.settings.matchListHeight;
this.matchList.style.width = `${savedWidth}px`;
this.matchList.style.maxWidth = `${savedWidth}px`;
this.matchList.style.height = `${savedHeight}px`;
}
}
positionPreviewPanel() {
if (!this.previewPanel || !this.matchList) return;
let panelWidth = Math.min(this.previewSize.width, window.innerWidth - 100);
let panelHeight = Math.min(this.previewSize.height, window.innerHeight - 100);
panelWidth = Math.max(300, panelWidth);
panelHeight = Math.max(200, panelHeight);
this.previewPanel.style.width = `${panelWidth}px`;
this.previewPanel.style.height = `${panelHeight}px`;
const listWidth = this.matchList.offsetWidth || 250;
let listLeft, listTop, panelLeft, panelTop;
if (this.listFixedPosition.left !== null && this.listFixedPosition.top !== null) {
listLeft = this.listFixedPosition.left;
listTop = this.listFixedPosition.top;
listLeft = Math.max(0, Math.min(listLeft, window.innerWidth - listWidth));
listTop = Math.max(0, Math.min(listTop, window.innerHeight - 100));
panelLeft = listLeft + listWidth + 10;
panelTop = listTop;
if (panelLeft + panelWidth > window.innerWidth - 10) {
panelLeft = Math.max(10, window.innerWidth - panelWidth - 10);
listLeft = panelLeft - listWidth - 10;
if (listLeft < 10) {
listLeft = 10;
panelWidth = Math.min(panelWidth, window.innerWidth - listWidth - 30);
this.previewPanel.style.width = `${panelWidth}px`;
panelLeft = listLeft + listWidth + 10;
}
}
} else {
panelLeft = window.innerWidth - panelWidth - 20;
panelTop = 50;
if (panelLeft < listWidth + 30) {
panelLeft = listWidth + 30;
panelWidth = Math.min(panelWidth, window.innerWidth - listWidth - 50);
this.previewPanel.style.width = `${panelWidth}px`;
}
if (panelTop + panelHeight > window.innerHeight - 10) {
panelTop = Math.max(10, window.innerHeight - panelHeight - 10);
}
listLeft = panelLeft - listWidth - 10;
listTop = panelTop;
if (listLeft < 10) {
listLeft = 10;
panelLeft = listLeft + listWidth + 10;
}
}
this.matchList.style.left = `${listLeft}px`;
this.matchList.style.top = `${listTop}px`;
this.listFixedPosition.left = listLeft;
this.listFixedPosition.top = listTop;
this.previewListPosition.left = listLeft;
this.previewListPosition.top = listTop;
this.previewPanel.style.left = `${panelLeft}px`;
this.previewPanel.style.top = `${panelTop}px`;
this.previewPanel.style.opacity = this.previewOpacity;
}
hideMatchList() {
if (this.matchList) {
// Skip if already hidden — avoid overwriting saved scroll position with 0
if (this.matchList.style.display === 'none') {
return;
}
const listContainer = this.matchList.querySelector('.minimap-match-list-container');
if (listContainer) {
const currentTerm = this._pendingShowList?.searchText || this._pendingSearchText;
if (currentTerm) {
this._listScrollPositions[currentTerm] = listContainer.scrollTop;
}
this._matchListScrollTop = listContainer.scrollTop;
}
// Save the list state so it can be restored exactly when reopened
const savedTerm = this._pendingShowList?.searchText || this._pendingSearchText;
if (savedTerm) {
this._lastListSearchTerm = savedTerm;
this._lastListFileMap = this._cachedMatchList;
this._lastListMatchCount = this._pendingMatchCount;
}
const favSection = this.matchList.querySelector('.swift-match-favorites-section');
if (favSection) {
this._favSectionScrollTop = favSection.scrollTop;
}
this.saveListData();
this.matchList.style.display = 'none';
}
if (this._matchListPositionUpdater) {
window.removeEventListener('resize', this._matchListPositionUpdater);
this._matchListPositionUpdater = null;
}
if (this._listCloseHandler) {
document.removeEventListener('mousedown', this._listCloseHandler);
this._listCloseHandler = null;
}
this._pendingListClose = false;
this._isListVisible = false;
this._listUserDismissed = true;
this._searchGeneration++;
this._pendingShowList = null;
this._listShownFromHover = false;
this._listTriggerElement = null;
// Don't clear _listPinnedSearchText - pin persists even when list is hidden
this.listFixedPosition.left = null;
this.listFixedPosition.top = null;
if (this.floatingSearchBox) {
this.floatingSearchBox.value = '';
}
}
addEditorDecorations(cm, matchLines, totalMatches) {
if (!cm || !cm.scrollDOM) return;
const scheme = this.getCurrentSelectionColorScheme();
const borderColor = scheme.borderColor;
const matchOpacity = this.settings.matchOpacity ?? 0.6;
const counterColor = scheme.counterColor;
const counterBgColor = scheme.counterBgColor;
const counterSize = this.settings.counterSize;
const counterPadding = '0px 2px';
const counterTopOffset = `${this.settings.counterTopOffset}px`;
cm.scrollDOM.style.setProperty('--minimap-match-color', borderColor);
cm.scrollDOM.style.setProperty('--minimap-counter-opacity', 1);
cm.scrollDOM.style.setProperty('--minimap-counter-color', counterColor);
cm.scrollDOM.style.setProperty('--minimap-counter-bgcolor', this.hexToRgba(counterBgColor, 1));
cm.scrollDOM.style.setProperty('--minimap-counter-size', `${counterSize}px`);
cm.scrollDOM.style.setProperty('--minimap-counter-padding', counterPadding);
cm.scrollDOM.style.setProperty('--minimap-counter-top-offset', counterTopOffset);
const decorations = [];
for (let idx = 0; idx < matchLines.length; idx++) {
const match = matchLines[idx];
const line = cm.state.doc.line(match.line + 1);
const from = line.from + match.pos;
const to = from + this.currentSelection.length;
const label = `${match.index}/${totalMatches}`;
const currentItem = this.savedMatchLists.find(m => m.selection === this.currentSelection);
const hideCounter = currentItem && currentItem.countHidden;
decorations.push({
from: from,
to: to,
value: Decoration.mark({
class: `minimap-editor-match${this.getCounterPresetClass()}${hideCounter ? ' minimap-hide-counter' : ''}`,
attributes: {
'data-match': label,
'style': `margin:0 -0.2em;padding:0 0.2em;-webkit-box-decoration-break:clone;box-decoration-break:clone;background:radial-gradient(farthest-side,${this.hexToRgba(borderColor, matchOpacity)} 98%,#0000) bottom left,linear-gradient(${this.hexToRgba(borderColor, matchOpacity)} 0 0) bottom,radial-gradient(farthest-side,${this.hexToRgba(borderColor, matchOpacity)} 98%,#0000) bottom right;background-size:8px 8px,calc(100% - 8px) 8px;background-repeat:no-repeat;`
}
})
});
}
const decorationSet = Decoration.set(decorations.sort((a, b) => a.from - b.from));
cm.dispatch({
effects: setMatchHighlights.of(decorationSet)
});
}
addEditorDecorationsWithPinned(cm, matchLines, totalMatches) {
if (!cm || !cm.scrollDOM) return;
const matchOpacity = this.settings.matchOpacity ?? 0.6;
const counterSize = this.settings.counterSize;
const counterPadding = '0px 2px';
const counterTopOffset = `${this.settings.counterTopOffset}px`;
cm.scrollDOM.style.setProperty('--minimap-counter-opacity', 1);
cm.scrollDOM.style.setProperty('--minimap-counter-size', `${counterSize}px`);
cm.scrollDOM.style.setProperty('--minimap-counter-padding', counterPadding);
cm.scrollDOM.style.setProperty('--minimap-counter-top-offset', counterTopOffset);
this.registerEditorField();
const content = cm.state.doc.toString();
const lines = content.split('\n');
const allDecorations = [];
const currentFile = this.app.workspace.activeLeaf?.view?.file;
const currentFilePath = currentFile ? currentFile.path : null;
const currentPreset = this.settings.counterStylePreset || 'glass';
// Add pinned decorations first
const pinnedItems = this.savedMatchLists.filter(item => item.pinned && (!currentFilePath || !item.filePath || item.filePath === currentFilePath));
for (const pinnedItem of pinnedItems) {
const colorScheme = this.getPinnedColorScheme(pinnedItem);
const pBorderColor = colorScheme.borderColor;
let pCounterBgColor = colorScheme.counterBgColor;
let pCounterColor = colorScheme.counterColor;
// 描边气泡样式下,计数边框和文字也跟随匹配透明度
if (currentPreset === 'outlined') {
pCounterBgColor = this.hexToRgba(pCounterBgColor, matchOpacity);
pCounterColor = this.hexToRgba(pCounterColor, matchOpacity);
}
const searchLower = pinnedItem.selection.toLowerCase();
// First pass: count total matches
let totalPinnedMatches = 0;
for (let i = 0; i < lines.length; i++) {
const lineLower = lines[i].toLowerCase();
let pos = 0;
while ((pos = lineLower.indexOf(searchLower, pos)) !== -1) {
totalPinnedMatches++;
pos += 1;
}
}
// Second pass: create decorations
let matchIndex = 0;
for (let i = 0; i < lines.length; i++) {
const lineLower = lines[i].toLowerCase();
let pos = 0;
while ((pos = lineLower.indexOf(searchLower, pos)) !== -1) {
matchIndex++;
try {
const line = cm.state.doc.line(i + 1);
const from = line.from + pos;
const to = from + pinnedItem.selection.length;
allDecorations.push({
from: from,
to: to,
value: Decoration.mark({
class: `minimap-editor-match pinned${this.getCounterPresetClass()}${pinnedItem.countHidden ? ' minimap-hide-counter' : ''}`,
attributes: {
'data-selection': pinnedItem.selection,
'data-match': `${matchIndex}/${totalPinnedMatches}`,
'style': `margin:0 -0.2em;padding:0 0.2em;-webkit-box-decoration-break:clone;box-decoration-break:clone;background:radial-gradient(farthest-side,${this.hexToRgba(pBorderColor, matchOpacity)} 98%,#0000) bottom left,linear-gradient(${this.hexToRgba(pBorderColor, matchOpacity)} 0 0) bottom,radial-gradient(farthest-side,${this.hexToRgba(pBorderColor, matchOpacity)} 98%,#0000) bottom right;background-size:8px 8px,calc(100% - 8px) 8px;background-repeat:no-repeat;--minimap-counter-bgcolor: ${pCounterBgColor};--minimap-counter-color: ${pCounterColor};`
}
})
});
} catch (e) {}
pos += 1;
}
}
}
// Add current selection decorations on top (using color scheme)
// Skip if current selection is already pinned (pinned decoration already covers it)
const isCurrentPinned = this.currentSelection && this.savedMatchLists.some(m => m.selection === this.currentSelection && m.pinned);
if (!isCurrentPinned) {
const currentColorScheme = this.getCurrentSelectionColorScheme();
const currentBorderColor = currentColorScheme.borderColor;
let currentCounterBgColor = currentColorScheme.counterBgColor;
let currentCounterColor = currentColorScheme.counterColor;
// 描边气泡样式下,计数边框和文字也跟随匹配透明度
if (currentPreset === 'outlined') {
currentCounterBgColor = this.hexToRgba(currentCounterBgColor, matchOpacity);
currentCounterColor = this.hexToRgba(currentCounterColor, matchOpacity);
}
for (let idx = 0; idx < matchLines.length; idx++) {
const match = matchLines[idx];
try {
const line = cm.state.doc.line(match.line + 1);
const from = line.from + match.pos;
const to = from + this.currentSelection.length;
const label = `${match.index}/${totalMatches}`;
const isJumpTarget = this._jumpHighlightRange && from === this._jumpHighlightRange.from && to === this._jumpHighlightRange.to;
if (isJumpTarget) {
allDecorations.push({
from: from,
to: to,
value: Decoration.mark({
class: 'minimap-editor-match hl-marshmallow',
attributes: {
'data-match': label,
'style': `background:#fff !important;color:#cc2e7a !important;border:2px solid #ff6eb4 !important;border-radius:10px !important;padding:0.1em 0.6em !important;box-shadow:2px 2px 0 #ffaad9 !important;margin:0 -0.2em;-webkit-box-decoration-break:clone;box-decoration-break:clone;--minimap-counter-bgcolor:#ff6eb4;--minimap-counter-color:#fff;`
}
})
});
} else {
allDecorations.push({
from: from,
to: to,
value: Decoration.mark({
class: `minimap-editor-match${this.getCounterPresetClass()}`,
attributes: {
'data-match': label,
'style': `margin:0 -0.2em;padding:0 0.2em;-webkit-box-decoration-break:clone;box-decoration-break:clone;background:radial-gradient(farthest-side,${this.hexToRgba(currentBorderColor, matchOpacity)} 98%,#0000) bottom left,linear-gradient(${this.hexToRgba(currentBorderColor, matchOpacity)} 0 0) bottom,radial-gradient(farthest-side,${this.hexToRgba(currentBorderColor, matchOpacity)} 98%,#0000) bottom right;background-size:8px 8px,calc(100% - 8px) 8px;background-repeat:no-repeat;--minimap-counter-bgcolor: ${currentCounterBgColor};--minimap-counter-color: ${currentCounterColor};`
}
})
});
}
} catch (e) {}
}
}
if (this._jumpHighlightRange) {
const jFrom = this._jumpHighlightRange.from;
const jTo = this._jumpHighlightRange.to;
allDecorations.push({
from: jFrom,
to: jTo,
value: Decoration.mark({
class: 'minimap-editor-match hl-marshmallow',
attributes: {
'style': `background:#fff !important;color:#cc2e7a !important;border:2px solid #ff6eb4 !important;border-radius:10px !important;padding:0.1em 0.6em !important;box-shadow:2px 2px 0 #ffaad9 !important;margin:0 -0.2em;-webkit-box-decoration-break:clone;box-decoration-break:clone;`
}
})
});
}
if (allDecorations.length > 0) {
const decorationSet = Decoration.set(allDecorations.sort((a, b) => a.from - b.from));
cm.dispatch({
effects: setMatchHighlights.of(decorationSet)
});
}
}
clearHighlights() {
this.highlights.forEach((h) => {
if (h.parentNode) h.parentNode.removeChild(h);
});
this.highlights = [];
this.editorHighlights = [];
if (this.isReadingMode()) {
this.clearReadingViewHighlights();
} else {
const editor = this.getEditor();
if (editor && editor.cm) {
editor.cm.dispatch({
effects: setMatchHighlights.of(Decoration.none)
});
}
}
// Pin only locks the word, not the list visibility - list can still be hidden
// But if _keepListVisible flag is set (e.g. from recent search chip click), don't hide
if (!this._keepListVisible) {
this.hideMatchList();
}
// Preserve _pendingMatchCount and _pendingSearchText so match list can be restored
// when reopened. Only clear the badge display when there's truly no cached data.
if (!this._cachedMatchList || this._cachedMatchList.size === 0) {
this._pendingMatchCount = 0;
this._pendingSearchText = null;
this.updateFloatingToggleBadge(0, 0);
}
}
showPinnedDecorations() {
const currentFile = this.app.workspace.activeLeaf?.view?.file;
const currentFilePath = currentFile ? currentFile.path : null;
const pinnedItems = this.savedMatchLists.filter(item => item.pinned && (!currentFilePath || !item.filePath || item.filePath === currentFilePath));
if (pinnedItems.length === 0) return;
if (this.isReadingMode()) {
for (const pinnedItem of pinnedItems) {
const colorScheme = this.getPinnedColorScheme(pinnedItem);
this.addReadingViewHighlights(pinnedItem.selection, pinnedItem.matchCount || 0, colorScheme);
}
return;
}
const editor = this.getEditor();
if (!editor || !editor.cm) return;
const cm = editor.cm;
const content = editor.getValue();
const lines = content.split('\n');
this.registerEditorField();
const matchOpacity = this.settings.matchOpacity ?? 0.6;
const decorations = [];
for (const pinnedItem of pinnedItems) {
const colorScheme = this.getPinnedColorScheme(pinnedItem);
const borderColor = colorScheme.borderColor;
let counterBgColor = colorScheme.counterBgColor;
let counterColor = colorScheme.counterColor;
// 描边气泡样式下,计数边框和文字也跟随匹配透明度
const preset = this.settings.counterStylePreset || 'glass';
if (preset === 'outlined') {
counterBgColor = this.hexToRgba(counterBgColor, matchOpacity);
counterColor = this.hexToRgba(counterColor, matchOpacity);
}
const searchLower = pinnedItem.selection.toLowerCase();
// First pass: count total matches
let totalPinnedMatches = 0;
for (let i = 0; i < lines.length; i++) {
const lineLower = lines[i].toLowerCase();
let pos = 0;
while ((pos = lineLower.indexOf(searchLower, pos)) !== -1) {
totalPinnedMatches++;
pos += 1;
}
}
// Second pass: create decorations
let matchIndex = 0;
for (let i = 0; i < lines.length; i++) {
const lineLower = lines[i].toLowerCase();
let pos = 0;
while ((pos = lineLower.indexOf(searchLower, pos)) !== -1) {
matchIndex++;
try {
const line = cm.state.doc.line(i + 1);
const from = line.from + pos;
const to = from + pinnedItem.selection.length;
decorations.push({
from: from,
to: to,
value: Decoration.mark({
class: `minimap-editor-match pinned${this.getCounterPresetClass()}${pinnedItem.countHidden ? ' minimap-hide-counter' : ''}`,
attributes: {
'data-selection': pinnedItem.selection,
'data-match': `${matchIndex}/${totalPinnedMatches}`,
'style': `margin:0 -0.2em;padding:0 0.2em;-webkit-box-decoration-break:clone;box-decoration-break:clone;background:radial-gradient(farthest-side,${this.hexToRgba(borderColor, matchOpacity)} 98%,#0000) bottom left,linear-gradient(${this.hexToRgba(borderColor, matchOpacity)} 0 0) bottom,radial-gradient(farthest-side,${this.hexToRgba(borderColor, matchOpacity)} 98%,#0000) bottom right;background-size:8px 8px,calc(100% - 8px) 8px;background-repeat:no-repeat;--minimap-counter-bgcolor: ${counterBgColor};--minimap-counter-color: ${counterColor};`
}
})
});
} catch (e) {
// Line may have been deleted
}
pos += 1;
}
}
}
if (decorations.length > 0) {
const decorationSet = Decoration.set(decorations.sort((a, b) => a.from - b.from));
cm.dispatch({
effects: setMatchHighlights.of(decorationSet)
});
}
}
getCounterPresetClass() {
const preset = this.settings.counterStylePreset || 'glass';
return ` minimap-counter-${preset}`;
}
parseMultiKeywords(searchText) {
if (!searchText || !searchText.includes('|')) return null;
const keywords = searchText.split('|').map(k => k.trim()).filter(k => k.length > 0);
return keywords.length >= 2 ? keywords : null;
}
getMultiKeywordColors(count) {
const palettes = [
'#ff6600', '#2196F3', '#4CAF50', '#9C27B0', '#FF9800',
'#E91E63', '#00BCD4', '#8BC34A', '#FF5722', '#607D8B'
];
const colors = [];
for (let i = 0; i < count; i++) {
colors.push(palettes[i % palettes.length]);
}
return colors;
}
async performMultiKeywordSearch(searchText, keywords) {
this._searchGeneration++;
const gen = this._searchGeneration;
this._searchInProgress = true;
const keywordColors = this.getMultiKeywordColors(keywords.length);
const fileResults = new Map();
const extractSnippetsForKeyword = (content, kwLower, maxSnippets = 30) => {
const lines = content.split('\n');
const snippets = [];
for (let i = 0; i < lines.length && snippets.length < maxSnippets; i++) {
const lineLower = lines[i].toLowerCase();
const idx = lineLower.indexOf(kwLower);
if (idx !== -1) {
const trimmed = lines[i].trim();
if (trimmed.length > 0) {
let snippet = trimmed;
let fullLine = trimmed;
let truncateStart = 0;
let truncateEnd = trimmed.length;
if (snippet.length > 120) {
const matchIdx = snippet.toLowerCase().indexOf(kwLower);
truncateStart = Math.max(0, matchIdx - 30);
truncateEnd = Math.min(snippet.length, matchIdx + kwLower.length + 70);
snippet = snippet.slice(truncateStart, truncateEnd);
}
snippets.push({ text: snippet, fullLine, truncateStart, truncateEnd });
}
}
}
return snippets;
};
try {
for (let ki = 0; ki < keywords.length; ki++) {
if (this._searchGeneration !== gen) { this._searchInProgress = false; return; }
const kw = keywords[ki];
const kwLower = kw.toLowerCase();
const allFiles = this.app.vault.getMarkdownFiles();
for (const file of allFiles) {
if (this._searchGeneration !== gen) { this._searchInProgress = false; return; }
try {
const nameMatch = file.name.toLowerCase().includes(kwLower);
const content = await this.app.vault.cachedRead(file);
if (this._searchGeneration !== gen) { this._searchInProgress = false; return; }
const contentMatch = content && content.toLowerCase().includes(kwLower);
if (nameMatch || contentMatch) {
if (!fileResults.has(file.path)) {
fileResults.set(file.path, { file, keywordHits: [], totalMatches: 0 });
}
const entry = fileResults.get(file.path);
const matchCount = contentMatch
? (content.toLowerCase().split(kwLower).length - 1)
: 1;
const snippets = contentMatch ? extractSnippetsForKeyword(content, kwLower) : [];
entry.keywordHits.push({
keyword: kw,
keywordIndex: ki,
color: keywordColors[ki],
matchCount,
snippets
});
entry.totalMatches += matchCount;
}
} catch (e) {
continue;
}
}
}
} catch (e) {
console.error('Multi-keyword search error:', e);
}
if (this._searchGeneration !== gen) { this._searchInProgress = false; return; }
const sortedEntries = [...fileResults.values()].filter(entry => {
if (this.settings.multiKeywordRequireAll !== false) {
return entry.keywordHits.length === keywords.length;
}
return true;
}).sort((a, b) => {
if (b.keywordHits.length !== a.keywordHits.length) {
return b.keywordHits.length - a.keywordHits.length;
}
return b.totalMatches - a.totalMatches;
});
const fileMap = new Map();
let totalAllDocMatches = 0;
for (const entry of sortedEntries.slice(0, 200)) {
const items = [];
for (const hit of entry.keywordHits) {
if (hit.snippets.length > 0) {
items.push({
text: hit.snippets[0].text,
level: 0,
type: 'content',
snippets: hit.snippets,
keyword: hit.keyword,
keywordIndex: hit.keywordIndex,
keywordColor: hit.color
});
} else {
items.push({
text: entry.file.basename,
level: 0,
type: 'content',
keyword: hit.keyword,
keywordIndex: hit.keywordIndex,
keywordColor: hit.color
});
}
}
totalAllDocMatches += entry.totalMatches;
fileMap.set(entry.file, items);
}
this._searchInProgress = false;
this._cachedMatchList = fileMap;
this._cachedMatchListKey = searchText;
this._multiKeywordData = { keywords, keywordColors };
this._pendingShowList = { searchText, matchCount: totalAllDocMatches };
this._pendingMatchCount = totalAllDocMatches;
this._recentSearchCaches[searchText] = { fileMap, matchCount: totalAllDocMatches };
this.updateFloatingToggleBadge(fileMap.size, totalAllDocMatches);
if (this._isListVisible || this._keepListVisible) {
this.renderMatchList(fileMap, totalAllDocMatches, false);
}
}
getPinnedColorScheme(pinnedItem) {
const colorIndex = pinnedItem.colorIndex || 0;
const schemes = this.settings.pinColorSchemes || DEFAULT_SETTINGS.pinColorSchemes;
const scheme = { ...schemes[colorIndex % schemes.length] };
const preset = this.settings.counterStylePreset || 'glass';
if (preset === 'glass') {
// 毛玻璃: 计数颜色固定,深浅自动切换
const isDark = document.body.classList.contains('theme-dark');
scheme.counterColor = isDark ? '#e0dcdc' : '#aba6a6';
} else if (preset === 'outlined') {
// 描边气泡: borderLinked 时 counterBgColor = borderColor
if (scheme.borderLinked !== false) {
scheme.counterBgColor = scheme.borderColor;
}
// counterLinked 时 counterColor = counterBgColor
if (scheme.counterLinked !== false) {
scheme.counterColor = scheme.counterBgColor;
}
}
return scheme;
}
getCurrentSelectionColorScheme() {
const pinnedItems = this.savedMatchLists.filter(m => m.pinned);
const nextColorIndex = pinnedItems.length % this.settings.pinColorSchemes.length;
const scheme = { ...this.settings.pinColorSchemes[nextColorIndex] };
const preset = this.settings.counterStylePreset || 'glass';
if (preset === 'glass') {
const isDark = document.body.classList.contains('theme-dark');
scheme.counterColor = isDark ? '#e0dcdc' : '#aba6a6';
} else if (preset === 'outlined') {
if (scheme.borderLinked !== false) {
scheme.counterBgColor = scheme.borderColor;
}
if (scheme.counterLinked !== false) {
scheme.counterColor = scheme.counterBgColor;
}
}
return scheme;
}
updateMinimapContent() {
if (!this.minimapContent) return;
// Check if the minimap container's parent view is blacklisted
if (this.minimapContainer && this.minimapContainer.parentNode) {
const leafInfo = this._getViewInfoFromContainer(this.minimapContainer.parentNode);
if (leafInfo && this._isViewInfoBlacklisted(leafInfo)) {
this.minimapContainer.parentNode.removeChild(this.minimapContainer);
this.minimapContainer = null;
this.minimapContent = null;
this.slider = null;
return;
}
}
if (this.isReadingMode()) {
const file = this.app.workspace.activeLeaf?.view?.file;
if (file) {
this.app.vault.read(file).then(content => {
this.minimapContent.textContent = content;
setTimeout(() => {
this.updateViewport();
}, 50);
}).catch(() => {
// Retry after delay
setTimeout(() => this.updateMinimapContent(), 200);
});
} else {
setTimeout(() => this.updateMinimapContent(), 200);
}
} else {
const editor = this.getEditor();
if (!editor) {
setTimeout(() => this.updateMinimapContent(), 200);
return;
}
const content = editor.getValue();
if (!content) {
setTimeout(() => this.updateMinimapContent(), 200);
return;
}
this.minimapContent.textContent = content;
setTimeout(() => {
this.updateViewport();
}, 50);
}
}
updateViewport() {
if (!this.minimapContainer || !this.minimapContent) return;
const containerHeight = this.minimapContainer.clientHeight;
let lineCount = 0;
if (this.isReadingMode()) {
const content = this.minimapContent.textContent || '';
lineCount = content.split('\n').length;
} else {
const editor = this.getEditor();
if (!editor) return;
const content = editor.getValue();
lineCount = content.split('\n').length;
}
const lineHeightInMinimap = 2;
const naturalContentHeight = lineCount * lineHeightInMinimap;
const contentHeight = Math.min(containerHeight, naturalContentHeight);
this.minimapContent.style.height = `${contentHeight}px`;
this.minimapContent.style.fontSize = '2px';
this.minimapContent.style.lineHeight = '1';
if (!this.slider) return;
let scrollTop, scrollHeight, clientHeight;
if (this.isReadingMode()) {
const scrollEl = this.getReadingViewScrollEl();
if (!scrollEl) return;
scrollTop = scrollEl.scrollTop;
scrollHeight = scrollEl.scrollHeight;
clientHeight = scrollEl.clientHeight;
} else {
const editor = this.getEditor();
if (!editor || !editor.cm) return;
const scrollDOM = editor.cm.scrollDOM;
if (!scrollDOM) return;
scrollTop = scrollDOM.scrollTop;
scrollHeight = scrollDOM.scrollHeight;
clientHeight = scrollDOM.clientHeight;
}
if (scrollHeight <= clientHeight) {
this.slider.style.height = `${contentHeight}px`;
this.slider.style.top = '0px';
return;
}
const scrollRatio = scrollTop / (scrollHeight - clientHeight);
const viewportRatio = clientHeight / scrollHeight;
const sliderHeight = Math.max(20, viewportRatio * contentHeight);
const sliderTop = scrollRatio * (contentHeight - sliderHeight);
this.slider.style.height = `${sliderHeight}px`;
this.slider.style.top = `${sliderTop}px`;
}
onunload() {
if (this._themeObserver) this._themeObserver.disconnect();
this.closeSettingsPanel();
this.closeFloatingContextMenu();
this.closeFloatingEditPanel();
// Clean up custom style elements
const customStyleEl = document.getElementById('swift-match-custom-style');
if (customStyleEl) customStyleEl.remove();
const previewStyleEl = document.getElementById('swift-match-preview-style');
if (previewStyleEl) previewStyleEl.remove();
// Clean up keyword button custom styles
document.querySelectorAll('[id^="swift-match-keyword-custom-style-"],[id^="swift-match-keyword-preview-style-"]').forEach(el => el.remove());
this.hidePinIcon();
this.removeEditorPadding();
const statusBar = document.querySelector('.status-bar');
if (statusBar) {
statusBar.classList.remove('minimap-hide-statusbar');
}
if (this.boundMouseMove) {
document.removeEventListener('mousemove', this.boundMouseMove);
}
if (this.boundMouseUp) {
document.removeEventListener('mouseup', this.boundMouseUp);
}
if (this.boundWheel && this.minimapContainer) {
this.minimapContainer.removeEventListener('wheel', this.boundWheel);
}
if (this.boundContextMenu && this.minimapContainer) {
this.minimapContainer.removeEventListener('contextmenu', this.boundContextMenu);
}
if (this.boundListWheel && this.matchList) {
this.matchList.removeEventListener('wheel', this.boundListWheel);
}
if (this.boundListWheel && this.previewPanel) {
this.previewPanel.removeEventListener('wheel', this.boundListWheel);
}
if (this.scrollHandler) {
document.removeEventListener('scroll', this.scrollHandler, true);
if (this.editorScrollEl) {
this.editorScrollEl.removeEventListener('scroll', this.scrollHandler);
}
}
if (this.minimapContainer && this.minimapContainer.parentNode) {
this.minimapContainer.parentNode.removeChild(this.minimapContainer);
}
this._removeOutlineOnHover();
if (this.tooltip && this.tooltip.parentNode) {
this.tooltip.parentNode.removeChild(this.tooltip);
}
if (this.matchList && this.matchList.parentNode) {
this.matchList.parentNode.removeChild(this.matchList);
}
if (this.previewPanel && this.previewPanel.parentNode) {
this.previewPanel.parentNode.removeChild(this.previewPanel);
}
if (this._previewOutsideClickHandler) {
document.removeEventListener('mousedown', this._previewOutsideClickHandler);
}
if (this._listCloseHandler) {
document.removeEventListener('mousedown', this._listCloseHandler);
}
if (this._matchListPositionUpdater) {
window.removeEventListener('resize', this._matchListPositionUpdater);
}
if (this._readingModeMouseUpHandler) {
document.removeEventListener('mouseup', this._readingModeMouseUpHandler);
}
if (this._readingModeKeyUpHandler) {
document.removeEventListener('keyup', this._readingModeKeyUpHandler);
}
this._removeSwiftMatchExportNav();
this.clearReadingViewHighlights();
this.hideAllMatchListIndicators();
// Clean up floating toggle wrapper
if (this.floatingToggleWrapper && this.floatingToggleWrapper.parentNode) {
this.floatingToggleWrapper.parentNode.removeChild(this.floatingToggleWrapper);
}
// Clean up floating keyword buttons
for (const btnData of this._floatingKeywordButtons) {
if (btnData.wrapper && btnData.wrapper.parentNode) {
btnData.wrapper.parentNode.removeChild(btnData.wrapper);
}
}
this._floatingKeywordButtons = [];
if (this.matchListIndicator && this.matchListIndicator.parentNode) {
this.matchListIndicator.parentNode.removeChild(this.matchListIndicator);
}
}
}
module.exports = MinimapPlugin;