This commit is contained in:
Devon22 2025-08-08 00:00:17 +08:00
parent 11be87984d
commit 8e513a17e9
10 changed files with 370 additions and 38 deletions

View file

@ -1,7 +1,7 @@
{
"id": "gridexplorer",
"name": "GridExplorer",
"version": "2.8.3",
"version": "2.8.4",
"minAppVersion": "1.1.0",
"description": "Browse note files in a grid view.",
"author": "Devon22",

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "gridexplorer",
"version": "2.8.3",
"version": "2.8.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gridexplorer",
"version": "2.8.3",
"version": "2.8.4",
"license": "MIT",
"devDependencies": {
"@types/node": "^16.11.6",

View file

@ -1,6 +1,6 @@
{
"name": "gridexplorer",
"version": "2.8.3",
"version": "2.8.4",
"description": "Browse note files in a grid view.",
"main": "main.js",
"scripts": {

View file

@ -46,6 +46,7 @@ export class GridView extends ItemView {
hasKeyboardFocus: boolean = false; // 是否有鍵盤焦點
fileWatcher: FileWatcher; // 檔案監聽器
recentSources: string[] = []; // 歷史記錄
futureSources: string[] = []; // 未來紀錄(前進堆疊)
minMode: boolean = false; // 最小模式
showIgnoredFolders: boolean = false; // 顯示忽略資料夾
showDateDividers: boolean = false; // 顯示日期分隔器
@ -161,10 +162,9 @@ export class GridView extends ItemView {
return (this.leaf as any)?.pinned ?? false;
}
// 將來源加入歷史記錄LRU 去重)
// 1. 若已有相同紀錄先移除,確保唯一
// 2. 插入到陣列開頭,代表最新使用
// 3. 超過上限時裁切
// 將來源加入歷史記錄
// 1. 插入到陣列開頭,代表最新使用
// 2. 超過上限時裁切
public pushHistory(
mode: string,
path: string | null,
@ -182,12 +182,11 @@ export class GridView extends ItemView {
searchFilesNameOnly,
searchMediaFiles,
});
const existingIndex = this.recentSources.indexOf(key);
if (existingIndex !== -1) {
this.recentSources.splice(existingIndex, 1);
}
this.recentSources.unshift(key);
const limit = 15;
// 一旦有新的歷史被推入,清空 futureSources
this.futureSources = [];
const limit = 10;
if (this.recentSources.length > limit) {
this.recentSources.length = limit;
}
@ -1615,7 +1614,10 @@ export class GridView extends ItemView {
this.clearSelection();
return true;
} else if (redirectType === 'search') {
this.setSource('', '', true, redirectPath);
const searchLocationFiles = fileCache?.frontmatter?.searchLocationFiles || false;
const searchFilesNameOnly = fileCache?.frontmatter?.searchFilesNameOnly || false;
const searchMediaFiles = fileCache?.frontmatter?.searchMediaFiles || false;
this.setSource('', '', true, redirectPath, !searchLocationFiles, searchFilesNameOnly, searchMediaFiles);
this.clearSelection();
return true;
} else if (redirectType === 'uri') {
@ -1804,6 +1806,7 @@ export class GridView extends ItemView {
showDateDividers: this.showDateDividers,
showNoteTags: this.showNoteTags,
recentSources: this.recentSources,
futureSources: this.futureSources,
}
};
}
@ -1827,6 +1830,7 @@ export class GridView extends ItemView {
this.showDateDividers = state.state.showDateDividers ?? this.plugin.settings.dateDividerMode !== 'none';
this.showNoteTags = state.state.showNoteTags ?? this.plugin.settings.showNoteTags;
this.recentSources = state.state.recentSources ?? [];
this.futureSources = state.state.futureSources ?? [];
this.render();
}
}

View file

@ -3,9 +3,9 @@ import { App, TFile } from 'obsidian';
// 尋找筆記中的第一張圖片
export async function findFirstImageInNote(app: App, content: string) {
try {
const internalStyle = /!?\[\[(.*?\.(?:jpg|jpeg|png|gif|webp))(?:\|.*?)?\]\]/;
const markdownStyle = /!\[(.*?)\]\(\s*(\S+?(?:\.(?:jpg|jpeg|png|gif|webp)|format=(?:jpg|jpeg|png|gif|webp))[^\s)]*)\s*(?:\s+["'][^"']*["'])?\s*\)/;
const frontmatterUrl = /^[\w\-_]+:\s*(https?:\/\/\S+?(?:\.(?:jpg|jpeg|png|gif|webp)|format=(?:jpg|jpeg|png|gif|webp))[^\s]*)\s*$/;
const internalStyle = /!?\[\[(.*?\.(?:jpg|jpeg|png|gif|webp|avif))(?:\|.*?)?\]\]/;
const markdownStyle = /!\[(.*?)\]\(\s*(\S+?(?:\.(?:jpg|jpeg|png|gif|webp|avif)|format=(?:jpg|jpeg|png|gif|webp))[^\s)]*)\s*(?:\s+["'][^"']*["'])?\s*\)/;
const frontmatterUrl = /^[\w\-_]+:\s*(https?:\/\/\S+?(?:\.(?:jpg|jpeg|png|gif|webp|avif)|format=(?:jpg|jpeg|png|gif|webp))[^\s]*)\s*$/;
const internalMatch = content.match(new RegExp(`(?:${internalStyle.source}|${markdownStyle.source}|${frontmatterUrl.source})`, 'im'));

View file

@ -1,12 +1,19 @@
import { App, Modal } from 'obsidian';
import { t } from '../translations';
export interface SearchOptions {
searchLocationFiles: boolean;
searchFilesNameOnly: boolean;
searchMediaFiles: boolean;
}
interface InputModalOptions {
title: string;
placeholder: string;
defaultValue?: string;
inputType?: 'text' | 'url';
onSubmit: (value: string) => void;
onSubmit: (value: string, searchOptions?: SearchOptions) => void;
showSearchOptions?: boolean;
}
export class InputModal extends Modal {
@ -35,6 +42,73 @@ export class InputModal extends Modal {
cls: 'ge-input-field'
});
// 如果是搜尋文字,添加搜尋選項
let searchOptions: SearchOptions = {
searchLocationFiles: false,
searchFilesNameOnly: false,
searchMediaFiles: false
};
if (this.options.showSearchOptions || this.options.title === t('search_text')) {
const searchOptionsContainer = contentEl.createDiv('ge-search-options');
// 僅搜尋目前位置檔案選項
const searchScopeContainer = searchOptionsContainer.createDiv('ge-search-option');
const searchScopeCheckbox = searchScopeContainer.createEl('input', {
type: 'checkbox',
attr: {
id: 'searchLocationFiles'
}
}) as HTMLInputElement;
if (searchOptions.searchLocationFiles) {
searchScopeCheckbox.checked = true;
}
const searchScopeLabel = searchScopeContainer.createEl('label', { text: t('search_current_location_only') });
searchScopeLabel.setAttribute('for', 'searchLocationFiles');
// 只搜尋檔名選項
const searchNameContainer = searchOptionsContainer.createDiv('ge-search-option');
const searchNameCheckbox = searchNameContainer.createEl('input', {
type: 'checkbox',
attr: {
id: 'searchFilesNameOnly'
}
}) as HTMLInputElement;
if (searchOptions.searchFilesNameOnly) {
searchNameCheckbox.checked = true;
}
const searchNameLabel = searchNameContainer.createEl('label', { text: t('search_files_name_only') });
searchNameLabel.setAttribute('for', 'searchFilesNameOnly');
// 搜尋媒體檔案選項
const searchMediaFilesContainer = searchOptionsContainer.createDiv('ge-search-option');
const searchMediaFilesCheckbox = searchMediaFilesContainer.createEl('input', {
type: 'checkbox',
attr: {
id: 'searchMediaFiles'
}
}) as HTMLInputElement;
if (searchOptions.searchMediaFiles) {
searchMediaFilesCheckbox.checked = true;
}
const searchMediaFilesLabel = searchMediaFilesContainer.createEl('label', { text: t('search_media_files') });
searchMediaFilesLabel.setAttribute('for', 'searchMediaFiles');
// 更新搜尋選項
const updateSearchOptions = () => {
searchOptions = {
searchLocationFiles: searchScopeCheckbox.checked,
searchFilesNameOnly: searchNameCheckbox.checked,
searchMediaFiles: searchMediaFilesCheckbox.checked
};
};
// 添加事件監聽器
searchScopeCheckbox.addEventListener('change', updateSearchOptions);
searchNameCheckbox.addEventListener('change', updateSearchOptions);
searchMediaFilesCheckbox.addEventListener('change', updateSearchOptions);
}
// 創建按鈕容器
const buttonContainer = contentEl.createDiv('ge-button-container');
@ -53,7 +127,11 @@ export class InputModal extends Modal {
const performSubmit = () => {
const value = input.value.trim();
if (value) {
this.options.onSubmit(value);
if (this.options.showSearchOptions || this.options.title === t('search_text')) {
this.options.onSubmit(value, searchOptions);
} else {
this.options.onSubmit(value);
}
this.close();
}
};
@ -83,11 +161,12 @@ export class InputModal extends Modal {
}
// 便利函數:顯示搜尋輸入 modal
export function showSearchInputModal(app: App, onSubmit: (searchText: string) => void) {
export function showSearchInputModal(app: App, onSubmit: (searchText: string, searchOptions?: SearchOptions) => void) {
new InputModal(app, {
title: t('search_text'),
placeholder: t('enter_search_text'),
onSubmit
onSubmit,
showSearchOptions: true
}).open();
}

View file

@ -1,12 +1,13 @@
import { App, Modal, TFolder, TFile, FuzzySuggestModal } from 'obsidian';
import GridExplorerPlugin from '../main';
import { t } from '../translations';
import { showSearchInputModal, showUriInputModal } from './inputModal';
import { showSearchInputModal, showUriInputModal, SearchOptions } from './inputModal';
interface ShortcutOption {
type: 'mode' | 'folder' | 'file' | 'search' | 'uri';
value: string;
display: string;
searchOptions?: SearchOptions;
}
export class ShortcutSelectionModal extends Modal {
@ -66,11 +67,19 @@ export class ShortcutSelectionModal extends Modal {
// 點擊搜尋按鈕時打開搜尋輸入模態框
searchButton.addEventListener('click', () => {
showSearchInputModal(this.app, (searchText) => {
showSearchInputModal(this.app, (searchText, searchOptions) => {
const searchParams = new URLSearchParams();
if (searchOptions) {
searchParams.set('allFiles', searchOptions.searchLocationFiles.toString());
searchParams.set('nameOnly', searchOptions.searchFilesNameOnly.toString());
searchParams.set('mediaOnly', searchOptions.searchMediaFiles.toString());
}
this.onSubmit({
type: 'search',
value: searchText,
display: `🔎 ${searchText}`
display: `🔎 ${searchText}`,
searchOptions: searchOptions
});
this.close();
});

View file

@ -26,7 +26,7 @@ export function renderHeaderButton(gridView: GridView) {
}
});
// 添加上一步按鈕
// 添加回按鈕
const backButton = headerButtonsDiv.createEl('button', { attr: { 'aria-label': t('back') } });
setIcon(backButton, 'arrow-left');
backButton.addEventListener('click', async (event) => {
@ -35,6 +35,20 @@ export function renderHeaderButton(gridView: GridView) {
// 如果有歷史記錄
if (gridView.recentSources.length > 0) {
// 將當前狀態推入 futureSources 以便前進
const currentKey = JSON.stringify({
mode: gridView.sourceMode,
path: gridView.sourcePath,
searchQuery: gridView.searchQuery,
searchAllFiles: gridView.searchAllFiles,
searchFilesNameOnly: gridView.searchFilesNameOnly,
searchMediaFiles: gridView.searchMediaFiles,
});
gridView.futureSources.unshift(currentKey);
if (gridView.futureSources.length > 10) {
gridView.futureSources.length = 10;
}
// 取得最近一筆歷史記錄
const lastSource = JSON.parse(gridView.recentSources[0]);
gridView.recentSources.shift(); // 從歷史記錄中移除
@ -49,6 +63,52 @@ export function renderHeaderButton(gridView: GridView) {
lastSource.searchFilesNameOnly ?? false,
lastSource.searchMediaFiles ?? false
);
// 更新按鈕狀態
updateNavButtons();
}
});
// 添加前進按鈕
const forwardButton = headerButtonsDiv.createEl('button', { attr: { 'aria-label': t('forward') } });
setIcon(forwardButton, 'arrow-right');
forwardButton.addEventListener('click', async (event) => {
event.preventDefault();
event.stopPropagation();
// 如果有未來紀錄
if (gridView.futureSources.length > 0) {
// 將當前狀態推入 recentSources 以便返回
const currentKey = JSON.stringify({
mode: gridView.sourceMode,
path: gridView.sourcePath,
searchQuery: gridView.searchQuery,
searchAllFiles: gridView.searchAllFiles,
searchFilesNameOnly: gridView.searchFilesNameOnly,
searchMediaFiles: gridView.searchMediaFiles,
});
gridView.recentSources.unshift(currentKey);
if (gridView.recentSources.length > 10) {
gridView.recentSources.length = 10;
}
// 取得下一筆未來紀錄
const nextSource = JSON.parse(gridView.futureSources[0]);
gridView.futureSources.shift(); // 從未來紀錄中移除
// 設定來源及搜尋狀態(不記錄到歷史)
await gridView.setSource(
nextSource.mode,
nextSource.path || '',
false, // 不記錄到歷史
nextSource.searchQuery || '',
nextSource.searchAllFiles ?? true,
nextSource.searchFilesNameOnly ?? false,
nextSource.searchMediaFiles ?? false
);
// 更新按鈕狀態
updateNavButtons();
}
});
@ -59,7 +119,7 @@ export function renderHeaderButton(gridView: GridView) {
event.preventDefault();
const menu = new Menu();
// 添加歷史記錄
gridView.recentSources.forEach((sourceInfoStr, index) => {
try {
@ -135,14 +195,26 @@ export function renderHeaderButton(gridView: GridView) {
.setTitle(`${displayText}`)
.setIcon(`${icon}`)
.onClick(async () => {
// 找出當前點擊的紀錄索引
const clickedIndex = gridView.recentSources.findIndex(source => {
const parsed = JSON.parse(source);
return parsed.mode === mode && parsed.path === path;
// 將目前狀態與較新的歷史推入 futureSources
const currentKey = JSON.stringify({
mode: gridView.sourceMode,
path: gridView.sourcePath,
searchQuery: gridView.searchQuery,
searchAllFiles: gridView.searchAllFiles,
searchFilesNameOnly: gridView.searchFilesNameOnly,
searchMediaFiles: gridView.searchMediaFiles,
});
// 如果找到點擊的紀錄,清除它之上的紀錄
// 直接使用當前迴圈索引(允許重複項)
const clickedIndex = index;
if (clickedIndex !== -1) {
const newerHistory = gridView.recentSources.slice(0, clickedIndex);
const forwardStack = [...newerHistory].reverse();
gridView.futureSources = [...forwardStack, currentKey, ...gridView.futureSources];
if (gridView.futureSources.length > 10) {
gridView.futureSources.length = 10;
}
gridView.recentSources = gridView.recentSources.slice(clickedIndex + 1);
}
@ -156,18 +228,154 @@ export function renderHeaderButton(gridView: GridView) {
sourceInfo.searchFilesNameOnly ?? false,
sourceInfo.searchMediaFiles ?? false
);
// 更新按鈕狀態
updateNavButtons();
});
});
} catch (error) {
console.error('Failed to parse source info:', error);
}
});
menu.showAtMouseEvent(event);
}
});
// 添加右鍵選單支援
forwardButton.addEventListener('contextmenu', (event) => {
// 只有在有歷史記錄或未來記錄時才顯示右鍵選單
if (gridView.futureSources.length > 0) {
event.preventDefault();
const menu = new Menu();
// 添加未來記錄
gridView.futureSources.forEach((sourceInfoStr, index) => {
try {
const sourceInfo = JSON.parse(sourceInfoStr);
const { mode, path } = sourceInfo;
// 根據模式顯示圖示和文字
let displayText = '';
let icon = '';
switch (mode) {
case 'folder':
displayText = path || '/';
icon = 'folder';
break;
case 'bookmarks':
displayText = t('bookmarks_mode');
icon = 'bookmark';
break;
case 'search':
displayText = t('search_results');
icon = 'search';
break;
case 'backlinks':
displayText = t('backlinks_mode');
icon = 'links-coming-in';
break;
case 'outgoinglinks':
displayText = t('outgoinglinks_mode');
icon = 'links-going-out';
break;
case 'all-files':
displayText = t('all_files_mode');
icon = 'book-text';
break;
case 'recent-files':
displayText = t('recent_files_mode');
icon = 'calendar-days';
break;
case 'random-note':
displayText = t('random_note_mode');
icon = 'dice';
break;
case 'tasks':
displayText = t('tasks_mode');
icon = 'square-check-big';
break;
default:
if (mode.startsWith('custom-')) {
const customMode = gridView.plugin.settings.customModes.find(m => m.internalName === mode);
displayText = customMode ? customMode.displayName : t('custom_mode');
icon = 'puzzle';
} else {
displayText = mode;
icon = 'grid';
}
}
// 處理搜尋顯示文字
if (sourceInfo.searchQuery) {
if (sourceInfo.searchAllFiles) {
displayText = '"' + (sourceInfo.searchQuery || t('search_results')) + '"';
} else {
displayText += `: \"${sourceInfo.searchQuery}\"`;
}
}
menu.addItem((item) => {
item
.setTitle(`${displayText}`)
.setIcon(`${icon}`)
.onClick(async () => {
// 將目前狀態與較舊的 future 項目移至 recentSources
const currentKey = JSON.stringify({
mode: gridView.sourceMode,
path: gridView.sourcePath,
searchQuery: gridView.searchQuery,
searchAllFiles: gridView.searchAllFiles,
searchFilesNameOnly: gridView.searchFilesNameOnly,
searchMediaFiles: gridView.searchMediaFiles,
});
const clickedIndex = index;
let olderFuture: string[] = [];
if (clickedIndex !== -1) {
olderFuture = gridView.futureSources.slice(0, clickedIndex);
gridView.futureSources = gridView.futureSources.slice(clickedIndex + 1);
}
const backStack = [...olderFuture].reverse();
gridView.recentSources = [...backStack, currentKey, ...gridView.recentSources];
if (gridView.recentSources.length > 10) {
gridView.recentSources.length = 10;
}
await gridView.setSource(
mode,
path,
false,
sourceInfo.searchQuery || '',
sourceInfo.searchAllFiles ?? true,
sourceInfo.searchFilesNameOnly ?? false,
sourceInfo.searchMediaFiles ?? false
);
// 更新按鈕狀態
updateNavButtons();
});
});
} catch (error) {
console.error('Failed to parse source info:', error);
}
});
// 顯示歷史選單
menu.showAtMouseEvent(event);
}
});
// 更新返回/前進按鈕啟用狀態
const updateNavButtons = () => {
backButton.disabled = gridView.recentSources.length === 0;
forwardButton.disabled = gridView.futureSources.length === 0;
};
// 初始狀態
updateNavButtons();
// 添加新增筆記按鈕
const newNoteButton = headerButtonsDiv.createEl('button', { attr: { 'aria-label': t('new_note') } });
setIcon(newNoteButton, 'square-pen');
@ -310,11 +518,25 @@ export function renderHeaderButton(gridView: GridView) {
showSearchModal(gridView.app, gridView, gridView.searchQuery, searchText);
});
// 先保存開啟 Modal 時的原始狀態
const originalSearchQuery = gridView.searchQuery;
const originalSearchAllFiles = gridView.searchAllFiles;
const originalSearchFilesNameOnly = gridView.searchFilesNameOnly;
const originalSearchMediaFiles = gridView.searchMediaFiles;
// 創建取消按鈕
const clearButton = searchTextContainer.createDiv('ge-clear-button');
setIcon(clearButton, 'x');
clearButton.addEventListener('click', (e) => {
e.stopPropagation(); // 防止觸發搜尋文字的點擊事件
gridView.pushHistory(
gridView.sourceMode,
gridView.sourcePath,
originalSearchQuery,
originalSearchAllFiles,
originalSearchFilesNameOnly,
originalSearchMediaFiles,
);
gridView.searchQuery = '';
gridView.clearSelection();
gridView.app.workspace.requestSaveLayout();
@ -538,9 +760,16 @@ function generateFilenameFromUri(uri: string): string {
// 創建捷徑檔案
async function createShortcut(
gridView: GridView,
option: { type: 'mode' | 'folder' | 'file' | 'search' | 'uri';
value: string;
display: string; }) {
option: {
type: 'mode' | 'folder' | 'file' | 'search' | 'uri';
value: string;
display: string;
searchOptions?: {
searchLocationFiles: boolean;
searchFilesNameOnly: boolean;
searchMediaFiles: boolean;
};
}) {
try {
// 生成不重複的檔案名稱
let counter = 0;
@ -582,6 +811,12 @@ async function createShortcut(
} else if (option.type === 'search') {
frontmatter.type = 'search';
frontmatter.redirect = option.value;
// 添加搜尋選項到 frontmatter
if (option.searchOptions) {
frontmatter.searchLocationFiles = option.searchOptions.searchLocationFiles;
frontmatter.searchFilesNameOnly = option.searchOptions.searchFilesNameOnly;
frontmatter.searchMediaFiles = option.searchOptions.searchMediaFiles;
}
} else if (option.type === 'uri') {
frontmatter.type = 'uri';
frontmatter.redirect = option.value;

View file

@ -546,7 +546,7 @@
/* 頂部按鈕區域樣式 Header Buttons */
.ge-header-buttons {
display: flex;
gap: 8px;
gap: 7px;
padding: 9px;
background: var(--background-primary);
border-bottom: 1px solid var(--background-modifier-border);
@ -558,7 +558,7 @@
display: flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
padding: 6px 11px;
background-color: var(--interactive-normal);
border-radius: var(--button-radius);
color: var(--text-normal);
@ -567,6 +567,11 @@
transition: background-color 0.2s, transform 0.1s;
}
.ge-header-buttons button:disabled {
opacity: 0.4;
cursor: default;
}
.is-tablet .ge-header-buttons button:not(.clickable-icon) {
padding: 6px 12px;
}

View file

@ -1,3 +1,3 @@
{
"2.8.3": "1.1.0"
"2.8.4": "1.1.0"
}