diff --git a/manifest.json b/manifest.json index 5cc142b..a6e784b 100644 --- a/manifest.json +++ b/manifest.json @@ -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", diff --git a/package-lock.json b/package-lock.json index 51e9c98..f0ac4c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 0ba238f..47cd6d6 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/GridView.ts b/src/GridView.ts index b4f0e3e..0b20e1d 100644 --- a/src/GridView.ts +++ b/src/GridView.ts @@ -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(); } } diff --git a/src/mediaUtils.ts b/src/mediaUtils.ts index 10b0ce5..4c71493 100644 --- a/src/mediaUtils.ts +++ b/src/mediaUtils.ts @@ -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')); diff --git a/src/modal/inputModal.ts b/src/modal/inputModal.ts index e18f2a2..c53cceb 100644 --- a/src/modal/inputModal.ts +++ b/src/modal/inputModal.ts @@ -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(); } diff --git a/src/modal/shortcutSelectionModal.ts b/src/modal/shortcutSelectionModal.ts index 5572486..dc588f3 100644 --- a/src/modal/shortcutSelectionModal.ts +++ b/src/modal/shortcutSelectionModal.ts @@ -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(); }); diff --git a/src/renderHeaderButton.ts b/src/renderHeaderButton.ts index 3ca6c2f..156687c 100644 --- a/src/renderHeaderButton.ts +++ b/src/renderHeaderButton.ts @@ -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; diff --git a/styles.css b/styles.css index 734bd7f..cd11d8e 100644 --- a/styles.css +++ b/styles.css @@ -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; } diff --git a/versions.json b/versions.json index fa3bcdd..e24f233 100644 --- a/versions.json +++ b/versions.json @@ -1,3 +1,3 @@ { - "2.8.3": "1.1.0" + "2.8.4": "1.1.0" } \ No newline at end of file