import { WorkspaceLeaf, ItemView, TFolder, TFile, Menu, Notice, Platform, setIcon, getFrontMatterInfo, FrontMatterCache, normalizePath, setTooltip } from 'obsidian'; import JSZip from 'jszip'; import GridExplorerPlugin from './main'; import { renderHeaderButton } from './renderHeaderButton'; import { renderModePath } from './renderModePath'; import { renderFolder } from './renderFolder'; import { renderFiles } from './renderFiles'; import { handleKeyDown } from './handleKeyDown'; import { isMediaFile, isImageFile, isVideoFile, isAudioFile, getFiles, IMAGE_EXTENSIONS, VIDEO_EXTENSIONS } from './utils/fileUtils'; import { FileWatcher } from './FileWatcher'; import { findFirstImageInNote, getFirstImageFromZip } from './utils/mediaUtils'; import { isHexColor, hexToRgba } from './utils/colorUtils'; import { showFolderSelectionModal } from './modal/folderSelectionModal'; import { MediaModal } from './modal/mediaModal'; import { showNoteSettingsModal } from './modal/noteSettingsModal'; import { showSearchModal } from './modal/searchModal'; import { FloatingAudioPlayer } from './FloatingAudioPlayer'; import { ExplorerView, EXPLORER_VIEW_TYPE } from './ExplorerView'; import { GridPreviewManager } from './GridPreviewManager'; import { t } from './translations'; // 定義分隔器狀態 interface DividerState { lastDateString: string; pinDividerAdded: boolean; blankDividerAdded: boolean; } // 定義檔案渲染參數 interface FileRenderParams { container: HTMLElement; observer: IntersectionObserver; files: TFile[]; dateDividerMode: string; sortType: string; shouldShowDateDividers: boolean; state: DividerState; } // 定義網格視圖 type GridViewLeaf = WorkspaceLeaf & { pinned?: boolean; updateHeader?: () => void; }; interface DataviewEventSource { on(name: 'dataview:index-ready', callback: () => void): ReturnType; } interface WorkspaceEventTrigger { trigger?: (name: 'ge-grid-source-changed', data: { mode: string; path: string }) => void; } interface InternalPlugins { plugins: { bookmarks?: { enabled?: boolean; }; }; getPluginById?: (id: string) => { instance?: { openGlobalSearch?: (query: string) => void; }; } | undefined; } interface AppWithInternalPlugins { internalPlugins?: InternalPlugins; } interface CommandManager { executeCommandById?: (id: string) => void; } interface AppWithCommands { commands?: CommandManager; } interface MenuItemWithWarning { setWarning: (warning: boolean) => void; } interface ExplorerViewActions { addToStash: (filePaths: string[]) => void; refresh: () => void; } interface WorkspaceSplitWithChildren { children?: unknown[]; } interface SourceInfo { mode: string; path?: string; searchQuery?: string; searchCurrentLocationOnly?: boolean; searchFilesNameOnly?: boolean; searchMediaFiles?: boolean; } interface GridViewStateData { sourceMode?: string; sourcePath?: string; baseSortType?: string; sortType?: string; searchQuery?: string; searchCurrentLocationOnly?: boolean; searchFilesNameOnly?: boolean; searchMediaFiles?: boolean; fileNameFilterQuery?: string; includeMedia?: boolean | 'media-only'; baseMinMode?: boolean; minMode?: boolean; showIgnoredItems?: boolean; baseCardLayout?: 'horizontal' | 'vertical'; cardLayout?: 'horizontal' | 'vertical'; hideHeaderElements?: boolean; showFileNameFilter?: boolean; baseShowDateDividers?: boolean; showDateDividers?: boolean; showNoteTags?: boolean; recentSources?: string[]; futureSources?: string[]; bookmarkGroupId?: string; } interface GridViewState { state?: GridViewStateData; } interface ShortcutFrontmatter extends FrontMatterCache { type?: unknown; redirect?: unknown; cardLayout?: unknown; searchCurrentLocationOnly?: unknown; searchFilesNameOnly?: unknown; searchMediaFiles?: unknown; } function getFrontmatterValue(frontmatter: FrontMatterCache | undefined, key: string): unknown { return frontmatter ? (frontmatter as Record)[key] : undefined; } function formatFrontmatterValue(value: unknown): string { if (typeof value === 'number') return value.toLocaleString(); if (typeof value === 'string') return value; if (typeof value === 'boolean') return value ? 'true' : 'false'; if (typeof value === 'bigint') return value.toString(); if (typeof value === 'symbol' || typeof value === 'function') return ''; if (Array.isArray(value)) return value.map(item => formatFrontmatterValue(item)).join(', '); if (value === null || value === undefined) return ''; if (typeof value === 'object') { try { return JSON.stringify(value); } catch { return ''; } } return ''; } export class GridView extends ItemView { plugin: GridExplorerPlugin; sourceMode: string = ''; // 模式選擇 sourcePath: string = ''; // 用於資料夾模式的路徑 baseSortType: string; // 使用者在設定或 UI 中選擇的基礎排序模式(不受資料夾臨時覆蓋影響) sortType: string; // 目前實際使用的排序模式(可能被資料夾 metadata 臨時覆蓋) searchQuery: string = ''; // 搜尋關鍵字 searchCurrentLocationOnly: boolean = false; // 是否只搜尋當前位置 searchFilesNameOnly: boolean = false; // 是否只搜尋筆記名稱 searchMediaFiles: boolean = false; // 是否搜尋媒體檔案 fileNameFilterQuery: string = ''; // 目前列表的檔名篩選關鍵字 includeMedia: boolean | 'media-only' = false; // 是否包含媒體檔案 selectedItemIndex: number = -1; // 當前選中的項目索引 selectedItems: Set = new Set(); // 存儲多選的項目索引 gridItems: HTMLElement[] = []; // 存儲所有網格項目的引用 hasKeyboardFocus: boolean = false; // 是否有鍵盤焦點 fileWatcher: FileWatcher | null = null; // 檔案監聽器 recentSources: string[] = []; // 歷史記錄 futureSources: string[] = []; // 未來紀錄(前進堆疊) baseMinMode: boolean = false; // 基礎最小模式(不受資料夾臨時覆蓋影響) minMode: boolean = false; // 目前實際使用的最小模式(可能被資料夾 metadata 臨時覆蓋) showIgnoredItems: boolean = false; // 顯示忽略的資料夾及檔案 baseShowDateDividers: boolean = false; // 基礎顯示日期分隔器狀態(不受資料夾臨時覆蓋影響) showDateDividers: boolean = false; // 目前實際使用的顯示日期分隔器狀態(可能被資料夾 metadata 臨時覆蓋) showNoteTags: boolean = false; // 顯示筆記標籤 showFileNameFilter: boolean = true; // 顯示檔名篩選輸入框 pinnedList: string[] = []; // 置頂清單 taskFilter: string = 'uncompleted'; // 任務分類 hideHeaderElements: boolean = false; // 是否隱藏標題列元素(模式名稱和按鈕) bookmarkGroupId: string = 'all'; // 書籤群組 ID customOptionIndex: number = -1; // 自訂模式選項索引 baseCardLayout: 'horizontal' | 'vertical' = 'horizontal'; // 使用者在設定或 UI 中選擇的基礎卡片樣式(不受資料夾臨時覆蓋影響) cardLayout: 'horizontal' | 'vertical' = 'horizontal'; // 目前實際使用的卡片樣式(可能被資料夾 metadata 臨時覆蓋) renderToken: number = 0; // 用於取消尚未完成之批次排程的遞增令牌 isShowingNote: boolean = false; // 是否正在顯示筆記 noteViewContainer: HTMLElement | null = null; // 筆記檢視容器 isShowingZip: boolean = false; // 是否正在顯示 ZIP 預覽 zipViewContainer: HTMLElement | null = null; // ZIP 檢視容器 zipThumbnailUrls: Map = new Map(); // 暫存縮圖 Blob URL zipObserver: IntersectionObserver | null = null; // 用於 Lazy loading activeZip: JSZip | null = null; // 解析後的 JSZip 物件 zipImageFiles: string[] = []; // ZIP 內的圖片路徑列表 zipCurrentIndex: number = -1; // 當前聚焦的圖片索引 eventCleanupFunctions: (() => void)[] = []; // 存儲事件清理函數 targetFocusPath: string | null = null; // 指定下次渲染要對焦的檔案路徑 previewManager: GridPreviewManager; constructor(leaf: WorkspaceLeaf, plugin: GridExplorerPlugin) { super(leaf); this.plugin = plugin; this.previewManager = new GridPreviewManager(this); this.containerEl.addClass('ge-grid-view-container'); this.baseSortType = this.plugin.settings.defaultSortType; // 使用設定中的預設排序模式 this.sortType = this.baseSortType; this.baseCardLayout = this.plugin.settings.cardLayout; this.cardLayout = this.baseCardLayout; this.baseMinMode = false; this.minMode = this.baseMinMode; this.baseShowDateDividers = this.plugin.settings.dateDividerMode !== 'none'; this.showDateDividers = this.baseShowDateDividers; this.showNoteTags = this.plugin.settings.showNoteTags; this.showFileNameFilter = this.plugin.settings.showFileNameFilter; this.searchCurrentLocationOnly = this.plugin.settings.searchCurrentLocationOnly; this.searchFilesNameOnly = this.plugin.settings.searchFilesNameOnly; this.searchMediaFiles = this.plugin.settings.searchMediaFiles; // 根據設定決定是否註冊檔案變更監聽器 if (this.plugin.settings.enableFileWatcher) { this.fileWatcher = new FileWatcher(plugin, this); this.fileWatcher.registerFileWatcher(); } // 在 window 層級以捕獲階段攔截快捷鍵,優先阻擋 Obsidian 內建快捷鍵並處理歷史前後退與關閉預覽 this.registerDomEvent(window, 'keydown', (event: KeyboardEvent) => { // 如果焦點在輸入框或可編輯區域,不處理 const target = event.target as HTMLElement | null; if (target && ( target.isContentEditable || target.closest('input, textarea, select, [contenteditable="true"]') )) { return; } if (event.key === 'ArrowLeft' && event.altKey) { // 僅在本視圖為活動視圖時才處理 if (this.app.workspace.getActiveViewOfType(GridView) !== this) return; // 有 modal 時不處理 if (activeDocument.querySelector('.modal-container')) return; // 阻止內建快捷鍵與其他監聽器 event.preventDefault(); // 停止後續所有監聽器(包含 Obsidian 內建 hotkey) event.stopImmediatePropagation(); if (this.isShowingNote || this.isShowingZip) { void this.previewManager.navigatePreviewBack(); } else { void this.navigateBack(); this.clearSelection(); } } else if (event.key === 'ArrowRight' && event.altKey) { // 僅在本視圖為活動視圖時才處理 if (this.app.workspace.getActiveViewOfType(GridView) !== this) return; // 有 modal 時不處理 if (activeDocument.querySelector('.modal-container')) return; // 阻止內建快捷鍵與其他監聽器 event.preventDefault(); // 停止後續所有監聽器(包含 Obsidian 內建 hotkey) event.stopImmediatePropagation(); if (this.isShowingNote || this.isShowingZip) { void this.previewManager.navigatePreviewForward(); } else { void this.navigateForward(); this.clearSelection(); } } else if ((event.key === 'ArrowUp' && event.altKey) || event.key === 'Backspace') { // 僅在本視圖為活動視圖時才處理 if (this.app.workspace.getActiveViewOfType(GridView) !== this) return; // 有 modal 時不處理 if (activeDocument.querySelector('.modal-container')) return; // 若正在顯示預覽,則關閉預覽 if (this.isShowingNote || this.isShowingZip) { event.preventDefault(); event.stopImmediatePropagation(); if (this.isShowingZip) { this.previewManager.hideZipInGrid(); } else { this.previewManager.hideNoteInGrid(); } } } }, true); // 註冊鍵盤事件處理 this.registerDomEvent(activeDocument, 'keydown', (event: KeyboardEvent) => { // 只有當 GridView 是活動視圖時才處理鍵盤事件 if (this.app.workspace.getActiveViewOfType(GridView) === this) { return handleKeyDown(this, event); } }); // 監聽 dataview:index-ready this.registerEvent( (this.app.metadataCache as unknown as DataviewEventSource).on('dataview:index-ready', () => { if (this.sourceMode.startsWith('custom-')) { void this.render(); } }) ); // 在行動端監聽分頁切換事件,當離開當前視圖時恢復導航欄 if (Platform.isPhone) { this.registerEvent( this.app.workspace.on('active-leaf-change', () => { const activeView = this.app.workspace.getActiveViewOfType(GridView); if (activeView !== this) { const navbar = activeDocument.querySelector('.mobile-navbar') as HTMLElement; if (navbar) { navbar.setCssProps({ transform: 'translateY(0)', transition: 'transform 0.3s ease-in', }); } } }) ); } } getViewType() { return 'grid-view'; } getIcon() { if (this.sourceMode.startsWith('custom-')) { return 'puzzle'; } else if (this.sourceMode === 'bookmarks') { return 'bookmark'; } else if (this.sourceMode === 'search') { return 'search'; } else if (this.sourceMode === 'backlinks') { return 'links-coming-in'; } else if (this.sourceMode === 'outgoinglinks') { return 'links-going-out'; } else if (this.sourceMode === 'all-files') { return 'book-text'; } else if (this.sourceMode === 'recent-files') { return 'calendar-days'; } else if (this.sourceMode === 'random-note') { return 'dice'; } else if (this.sourceMode === 'tasks') { return 'square-check-big'; } else if (this.sourceMode === 'folder') { return 'folder'; } else { return 'grid'; } } getDisplayText() { if (this.sourceMode.startsWith('custom-')) { const mode = this.plugin.settings.customModes.find(m => m.internalName === this.sourceMode); return mode ? mode.displayName : t('custom_mode'); } else if (this.sourceMode === '') { return t('grid_view_title'); } else if (this.sourceMode === 'bookmarks') { return t('bookmarks_mode'); } else if (this.sourceMode === 'search') { return t('search_results'); } else if (this.sourceMode === 'backlinks') { return t('backlinks_mode'); } else if (this.sourceMode === 'outgoinglinks') { return t('outgoinglinks_mode'); } else if (this.sourceMode === 'all-files') { return t('all_files_mode'); } else if (this.sourceMode === 'recent-files') { return t('recent_files_mode'); } else if (this.sourceMode === 'random-note') { return t('random_note_mode'); } else if (this.sourceMode === 'tasks') { return t('tasks_mode'); } else if (this.sourceMode === 'folder') { if (this.sourcePath === '/') { return t('root'); } return this.sourcePath; } else { return ''; } } // 判斷當前Leaf是否被釘選 isPinned(): boolean { return (this.leaf as GridViewLeaf).pinned ?? false; } // 將來源加入歷史記錄 // 1. 插入到陣列開頭,代表最新使用 // 2. 超過上限時裁切 public pushHistory( mode: string, path: string | null, searchQuery: string = '', searchCurrentLocationOnly: boolean = false, searchFilesNameOnly: boolean = false, searchMediaFiles: boolean = false, ) { const sanitizedPath = path ?? ''; const key = JSON.stringify({ mode, path: sanitizedPath, searchQuery, searchCurrentLocationOnly, searchFilesNameOnly, searchMediaFiles, }); this.recentSources.unshift(key); // 一旦有新的歷史被推入,清空 futureSources this.futureSources = []; const limit = 10; if (this.recentSources.length > limit) { this.recentSources.length = limit; } } private parseSourceInfo(sourceInfoStr: string): SourceInfo | null { try { return JSON.parse(sourceInfoStr) as SourceInfo; } catch { return null; } } // 歷史記錄後退 async navigateBack() { if (this.recentSources.length > 0) { const currentKey = JSON.stringify({ mode: this.sourceMode, path: this.sourcePath, searchQuery: this.searchQuery, searchCurrentLocationOnly: this.searchCurrentLocationOnly, searchFilesNameOnly: this.searchFilesNameOnly, searchMediaFiles: this.searchMediaFiles, }); this.futureSources.unshift(currentKey); if (this.futureSources.length > 10) { this.futureSources.length = 10; } const lastSource = this.parseSourceInfo(this.recentSources[0]); if (!lastSource) return; this.recentSources.shift(); // 從歷史記錄中移除 await this.setSource( lastSource.mode, lastSource.path || '', false, // 不記錄到歷史 lastSource.searchQuery || '', lastSource.searchCurrentLocationOnly ?? false, lastSource.searchFilesNameOnly ?? false, lastSource.searchMediaFiles ?? false ); } } // 歷史記錄前進 async navigateForward() { if (this.futureSources.length > 0) { const currentKey = JSON.stringify({ mode: this.sourceMode, path: this.sourcePath, searchQuery: this.searchQuery, searchCurrentLocationOnly: this.searchCurrentLocationOnly, searchFilesNameOnly: this.searchFilesNameOnly, searchMediaFiles: this.searchMediaFiles, }); this.recentSources.unshift(currentKey); if (this.recentSources.length > 10) { this.recentSources.length = 10; } const nextSource = this.parseSourceInfo(this.futureSources[0]); if (!nextSource) return; this.futureSources.shift(); // 從未來紀錄中移除 await this.setSource( nextSource.mode, nextSource.path || '', false, // 不記錄到歷史 nextSource.searchQuery || '', nextSource.searchCurrentLocationOnly ?? false, nextSource.searchFilesNameOnly ?? false, nextSource.searchMediaFiles ?? false ); } } // 設定來源模式 async setSource( mode: string, path = '', recordHistory = true, // 是否將當前狀態加入歷史記錄 searchQuery?: string, searchCurrentLocationOnly?: boolean, searchFilesNameOnly?: boolean, searchMediaFiles?: boolean, bookmarkGroupId?: string ) { // 如果新的狀態與當前狀態相同,則不進行任何操作 if (this.sourceMode === mode && this.sourcePath === path && this.searchQuery === searchQuery && this.searchCurrentLocationOnly === searchCurrentLocationOnly && this.searchFilesNameOnly === searchFilesNameOnly && this.searchMediaFiles === searchMediaFiles && (bookmarkGroupId === undefined || this.bookmarkGroupId === bookmarkGroupId)) { return; } // 當切換來源路徑或模式時,應先隱藏當前筆記/ZIP預覽,並清空預覽歷史記錄 if (this.isShowingNote) { this.hideNoteInGrid(); } if (this.isShowingZip) { this.hideZipInGrid(); } // 記錄之前的狀態到歷史記錄中(如果有) if (this.sourceMode && recordHistory) { this.pushHistory( this.sourceMode, this.sourcePath, this.searchQuery, this.searchCurrentLocationOnly, this.searchFilesNameOnly, this.searchMediaFiles, ); } // 全域搜尋時切換路徑則清空搜尋 if (this.searchQuery && !this.searchCurrentLocationOnly) { this.searchQuery = ''; } // 更新來源模式和路徑 if (mode !== '') this.sourceMode = mode; if (path !== '') this.sourcePath = path; if (this.sourceMode === '') this.sourceMode = 'folder'; if (this.sourcePath === '') this.sourcePath = '/'; // 讀取Folder設定 this.pinnedList = []; if (this.sourceMode === 'folder') { // 檢查是否有與資料夾同名的 md 檔案 const folderName = this.sourcePath.split('/').pop() || ''; const mdFilePath = `${this.sourcePath}/${folderName}.md`; const mdFile = this.app.vault.getAbstractFileByPath(mdFilePath); let tempLayout: 'horizontal' | 'vertical' = this.baseCardLayout; let folderSort: string | undefined; let tempMinMode: boolean = this.baseMinMode; let tempShowDateDividers: boolean = this.baseShowDateDividers; if (mdFile instanceof TFile) { const metadata = this.app.metadataCache.getFileCache(mdFile)?.frontmatter; folderSort = typeof metadata?.sort === 'string' ? metadata.sort : undefined; if (metadata?.cardLayout === 'horizontal' || metadata?.cardLayout === 'vertical') { tempLayout = metadata.cardLayout as 'horizontal' | 'vertical'; } if (metadata?.minMode === true || metadata?.minMode === 'true') { tempMinMode = true; } else if (metadata?.minMode === false || metadata?.minMode === 'false') { tempMinMode = false; } if (metadata?.showDateDividers === true || metadata?.showDateDividers === 'true') { tempShowDateDividers = true; } else if (metadata?.showDateDividers === false || metadata?.showDateDividers === 'false') { tempShowDateDividers = false; } } this.cardLayout = tempLayout; this.minMode = tempMinMode; this.showDateDividers = tempShowDateDividers; // 根據資料夾 frontmatter 的 sort 覆蓋實際排序,否則使用 baseSortType this.sortType = folderSort && typeof folderSort === 'string' && folderSort.trim() !== '' ? folderSort : this.baseSortType; } else { // 非資料夾模式時 this.cardLayout = this.baseCardLayout; // 回復基礎卡片排列 this.minMode = this.baseMinMode; // 回復基礎最小化模式 this.showDateDividers = this.baseShowDateDividers; // 回復基礎日期分隔器模式 this.sourcePath = '/'; // 強制設定路徑為根目錄 (創建筆記用) // 切換到自訂模式時:重設選項索引,並將排序設為 'none' // 其餘模式:恢復使用 baseSortType 作為實際排序 if (this.sourceMode.startsWith('custom-')) { this.customOptionIndex = -1; this.sortType = 'none'; } else { this.sortType = this.baseSortType; } } // 設定搜尋相關狀態 if (searchQuery !== undefined) { this.searchQuery = searchQuery; } if (searchCurrentLocationOnly !== undefined) { this.searchCurrentLocationOnly = searchCurrentLocationOnly; } if (searchFilesNameOnly !== undefined) { this.searchFilesNameOnly = searchFilesNameOnly; } if (searchMediaFiles !== undefined) { this.searchMediaFiles = searchMediaFiles; } if (bookmarkGroupId !== undefined) { this.bookmarkGroupId = bookmarkGroupId; } // 通知 Obsidian 保存視圖狀態 this.app.workspace.requestSaveLayout(); // 發送自訂事件,通知 ExplorerView 目前來源已變更 try { (this.app.workspace as WorkspaceEventTrigger).trigger?.('ge-grid-source-changed', { mode: this.sourceMode, path: this.sourcePath, }); } catch (error) { console.error('GridExplorer: Error triggering ge-grid-source-changed event', error); } await this.render(); } // 清理事件監聽器 onunload() { // 清理所有事件監聽器 this.eventCleanupFunctions.forEach(cleanup => { try { cleanup(); } catch (error) { console.warn('GridExplorer: Error during event cleanup:', error); } }); this.eventCleanupFunctions = []; // 清理檔案監聽器(FileWatcher 的事件監聽器會在插件卸載時自動清理) super.onunload(); } // 渲染網格 async render() { // 保存選中項目的檔案路徑(如果有) let selectedFilePath: string | null = null; if (this.selectedItemIndex >= 0 && this.selectedItemIndex < this.gridItems.length) { const selectedItem = this.gridItems[this.selectedItemIndex]; selectedFilePath = selectedItem.dataset.filePath || null; } // 清理之前的事件監聽器 this.eventCleanupFunctions.forEach(cleanup => { try { cleanup(); } catch (error) { console.warn('GridExplorer: Error during event cleanup:', error); } }); this.eventCleanupFunctions = []; // 清空整個容器,移除 Obsidian 原生的標題列以實現滿版 this.containerEl.empty(); // 添加頂部按鈕 renderHeaderButton(this); // 顯示路徑 / 模式名稱 renderModePath(this); if (this.showFileNameFilter) { // 顯示目前列表的檔名篩選輸入框 this.renderFileNameFilter(); } // 創建內容區域(使用自訂類別避開行動端 margin-top 影響) this.containerEl.createDiv('ge-view-content'); // 取得置頂清單 if (this.sourceMode === 'folder' && this.sourcePath !== '/') { this.pinnedList = []; const folderPath = this.sourcePath; if (!folderPath || folderPath === '/') return; const folderName = folderPath.split('/').pop() || ''; const notePath = `${folderPath}/${folderName}.md`; const noteFile = this.app.vault.getAbstractFileByPath(notePath); if (noteFile instanceof TFile) { const metadata = this.app.metadataCache.getFileCache(noteFile)?.frontmatter; if (metadata) { if (Array.isArray(metadata['pinned'])) { if (this.plugin.settings.folderNoteDisplaySettings === 'pinned') { // 先過濾掉所有重複的資料夾筆記 this.pinnedList = (metadata['pinned'] as string[]).filter((name: string) => name !== `${folderName}.md`); // 將資料夾筆記添加到最前面 this.pinnedList.unshift(`${folderName}.md`); } else { this.pinnedList = metadata['pinned'] as string[]; } } else if (this.plugin.settings.folderNoteDisplaySettings === 'pinned') { // 如果沒有置頂清單,則建立一個僅包含資料夾筆記的清單 this.pinnedList = [`${folderName}.md`]; } } } }; // 渲染網格內容 await this.grid_render(); (this.leaf as GridViewLeaf).updateHeader?.(); // 如果有之前選中的檔案路徑,嘗試恢復選中狀態 if (selectedFilePath && this.hasKeyboardFocus) { const newIndex = this.gridItems.findIndex(item => item.dataset.filePath === selectedFilePath); if (newIndex >= 0) { this.selectItem(newIndex); } } // new Notice('GridExplorer: ' + this.sourceMode + ' ' + this.sourcePath); } // 渲染檔名篩選框 renderFileNameFilter() { const filterContainer = this.containerEl.createDiv('ge-file-filter-container'); const contentEl = this.containerEl.querySelector('.ge-view-content'); if (contentEl) { this.containerEl.insertBefore(filterContainer, contentEl); } const inputWrapper = filterContainer.createDiv('ge-file-filter-input-wrapper'); const filterInput = inputWrapper.createEl('input', { type: 'text', cls: 'ge-file-filter-input', attr: { placeholder: t('file_name_filter_placeholder') } }); const searchButton = inputWrapper.createEl('button', { cls: 'ge-file-filter-search clickable-icon', attr: { 'aria-label': t('search') } }); setIcon(searchButton, 'arrow-right'); const clearButton = inputWrapper.createEl('button', { cls: 'ge-file-filter-clear clickable-icon', attr: { 'aria-label': t('clear_file_name_filter') } }); setIcon(clearButton, 'x'); const updateClearButton = () => { const hasContent = filterInput.value.length > 0; searchButton.toggleClass('is-visible', hasContent); clearButton.toggleClass('is-visible', hasContent); }; let filterRenderTimer: number | null = null; const renderFilteredFiles = async () => { filterRenderTimer = null; this.clearSelection(); await this.grid_render(); }; const scheduleFilteredRender = () => { if (filterRenderTimer !== null) { window.clearTimeout(filterRenderTimer); } filterRenderTimer = window.setTimeout(() => { void renderFilteredFiles(); }, 250); }; let isComposingFilterText = false; filterInput.value = this.fileNameFilterQuery; updateClearButton(); filterInput.addEventListener('compositionstart', () => { isComposingFilterText = true; if (filterRenderTimer !== null) { window.clearTimeout(filterRenderTimer); filterRenderTimer = null; } }); filterInput.addEventListener('compositionend', () => { isComposingFilterText = false; this.fileNameFilterQuery = filterInput.value; updateClearButton(); scheduleFilteredRender(); }); filterInput.addEventListener('input', (event: InputEvent) => { if (isComposingFilterText || event.isComposing) return; this.fileNameFilterQuery = filterInput.value; updateClearButton(); scheduleFilteredRender(); }); searchButton.addEventListener('click', (event) => { event.preventDefault(); event.stopPropagation(); const query = filterInput.value.trim(); if (!query) return; if (filterRenderTimer !== null) { window.clearTimeout(filterRenderTimer); filterRenderTimer = null; } // filterInput.value = ''; // this.fileNameFilterQuery = ''; updateClearButton(); showSearchModal(this.app, this, query, searchButton); }); clearButton.addEventListener('click', (event) => { void (async () => { event.preventDefault(); event.stopPropagation(); if (!filterInput.value) return; if (filterRenderTimer !== null) { window.clearTimeout(filterRenderTimer); filterRenderTimer = null; } filterInput.value = ''; this.fileNameFilterQuery = ''; updateClearButton(); this.clearSelection(); await this.grid_render(); filterInput.focus(); })(); }); } // 渲染網格內容 setFileNameFilterVisibility(show: boolean, filterButton?: HTMLElement) { this.showFileNameFilter = show; filterButton?.toggleClass('is-active', show); let fileNameFilterContainer = this.containerEl.querySelector('.ge-file-filter-container') as HTMLElement; if (!fileNameFilterContainer && show) { this.renderFileNameFilter(); fileNameFilterContainer = this.containerEl.querySelector('.ge-file-filter-container') as HTMLElement; } if (fileNameFilterContainer) { fileNameFilterContainer.style.display = this.hideHeaderElements || !show ? 'none' : 'block'; if (show && !this.hideHeaderElements) { const filterInput = fileNameFilterContainer.querySelector('.ge-file-filter-input'); filterInput?.focus(); } } } async grid_render() { const container = this.containerEl.querySelector('.ge-view-content') as HTMLElement; container.empty(); this.renderToken++; container.addClass('ge-grid-container'); // 隱藏頂部元素 const displayValue = this.hideHeaderElements ? 'none' : 'flex'; const headerButtons = this.containerEl.querySelector('.ge-header-buttons') as HTMLElement; const fileNameFilterContainer = this.containerEl.querySelector('.ge-file-filter-container') as HTMLElement; const modeHeaderContainer = this.containerEl.querySelector('.ge-mode-header-container') as HTMLElement; if (headerButtons) headerButtons.style.display = displayValue; if (fileNameFilterContainer) fileNameFilterContainer.style.display = this.hideHeaderElements || !this.showFileNameFilter ? 'none' : 'block'; if (modeHeaderContainer) modeHeaderContainer.style.display = displayValue; // 根據設定決定是否啟用卡片模式 if (this.cardLayout === 'vertical') { container.addClass('ge-vertical-card'); } else { container.removeClass('ge-vertical-card'); } // 添加點擊空白處取消選中的事件處理器 container.addEventListener('click', (event) => { // 只有當點擊的是容器本身,而不是其子元素時才清除選中 if (event.target === container) { this.clearSelection(); this.hasKeyboardFocus = false; } }); // 在移動端添加滾動監聽,根據滾動方向控制導航欄顯示/隱藏 if (Platform.isPhone) { let lastScrollTop = 0; let accumulateScroll = 0; const handleScroll = () => { const mobileNavbar = activeDocument.querySelector('.mobile-navbar') as HTMLElement; if (!mobileNavbar) return; const currentScrollTop = container.scrollTop; const delta = currentScrollTop - lastScrollTop; if (delta > 0) { // 往上捲(滾動位置增加) if (accumulateScroll < 0) accumulateScroll = 0; accumulateScroll += delta; if (accumulateScroll > 50 && currentScrollTop > 50) { if (!activeDocument.body.classList.contains('is-floating-nav')) { mobileNavbar.setCssProps({ transform: 'translateY(100%)' }); } else { mobileNavbar.setCssProps({ transform: 'translateY(200%)' }); } mobileNavbar.setCssProps({ transition: 'transform 0.3s ease-out' }); } } else if (delta < 0) { // 往下捲(滾動位置減少) if (accumulateScroll > 0) accumulateScroll = 0; accumulateScroll += delta; if (accumulateScroll < -50 || currentScrollTop <= 0) { mobileNavbar.setCssProps({ transform: 'translateY(0)', transition: 'transform 0.3s ease-in', }); } } lastScrollTop = currentScrollTop; }; container.addEventListener('scroll', handleScroll); // 儲存滾動事件清理函數 this.eventCleanupFunctions.push(() => { container.removeEventListener('scroll', handleScroll); }); } // 設定網格項目寬度和高度等設定 const settings = this.plugin.settings; const gridItemWidth = this.cardLayout === 'vertical' ? settings.verticalGridItemWidth : settings.gridItemWidth; const gridItemHeight = this.cardLayout === 'vertical' ? settings.verticalGridItemHeight : settings.gridItemHeight; const imageAreaWidth = settings.imageAreaWidth; const imageAreaHeight = this.cardLayout === 'vertical' ? settings.verticalImageAreaHeight : settings.imageAreaHeight; container.setCssProps({ '--grid-item-width': `${gridItemWidth}px`, '--grid-item-height': gridItemHeight === 0 || this.minMode ? '100%' : `${gridItemHeight}px`, '--image-area-width': `${imageAreaWidth}px`, '--image-area-height': `${imageAreaHeight}px`, '--title-font-size': `${settings.titleFontSize}em`, }); // 依圖片位置設定切換樣式類別 if (this.cardLayout === 'vertical') { if (settings.verticalCardImagePosition === 'top') { container.addClass('ge-image-top'); container.removeClass('ge-image-bottom'); } else { container.addClass('ge-image-bottom'); container.removeClass('ge-image-top'); } } // 定義所有可能的模式(不包括 custom-) const modeClasses = [ 'bookmarks', 'search', 'backlinks', 'outgoinglinks', 'all-files', 'recent-files', 'random-note', 'tasks', 'folder' ]; // 先移除所有模式相關的 class this.containerEl.removeClass('ge-mode-custom'); // 特別處理 custom 類別 modeClasses.forEach(mode => { this.containerEl.removeClass(`ge-mode-${mode}`); }); // 添加當前模式的 class if (this.sourceMode.startsWith('custom-')) { this.containerEl.addClass('ge-mode-custom'); } else if (modeClasses.includes(this.sourceMode)) { this.containerEl.addClass(`ge-mode-${this.sourceMode}`); } // 重置網格項目數組 this.gridItems = []; // 如果是書籤模式且書籤插件未啟用,顯示提示 if (this.sourceMode === 'bookmarks' && !(this.app as AppWithInternalPlugins).internalPlugins?.plugins.bookmarks?.enabled) { new Notice(t('bookmarks_plugin_disabled')); return; } // 如果是反向連結模式,但沒有活動中的檔案 if (this.sourceMode === 'backlinks' && !this.app.workspace.getActiveFile()) { const noFilesDiv = container.createDiv('ge-no-files'); noFilesDiv.setText(t('no_backlinks')); if (this.plugin.statusBarItem) { this.plugin.statusBarItem.setText(''); } return; } // 顯示資料夾 void renderFolder(this, container); // 顯示檔案 let files = await renderFiles(this, container); // 根據輸入框內容篩選目前列表中的檔案名稱 const fileNameFilterKeywords = this.fileNameFilterQuery .trim() .toLowerCase() .split(/\s+/) .filter(Boolean); if (this.showFileNameFilter && fileNameFilterKeywords.length > 0) { files = files.filter(file => { const fileName = file.name.toLowerCase(); return fileNameFilterKeywords.every(keyword => fileName.includes(keyword)); }); } // 如果沒有檔案,顯示提示訊息 if (files.length === 0) { const noFilesDiv = container.createDiv('ge-no-files'); if (this.sourceMode !== 'backlinks') { noFilesDiv.setText(t('no_files')); } else { noFilesDiv.setText(t('no_backlinks')); } if (this.plugin.statusBarItem) { this.plugin.statusBarItem.setText(''); } return; } // 若有置頂清單且目前為資料夾模式,將置頂檔案移到最前面並維持其在清單中的順序 if (this.pinnedList.length > 0 && this.sourceMode === 'folder') { const pinnedFiles = files.filter(f => this.pinnedList.includes(f.name)); // 依照 pinnedList 順序排序 pinnedFiles.sort((a, b) => this.pinnedList.indexOf(a.name) - this.pinnedList.indexOf(b.name)); const otherFiles = files.filter(f => !this.pinnedList.includes(f.name)); files = [...pinnedFiles, ...otherFiles]; } // 如果資料夾筆記設定為隱藏,則隱藏資料夾筆記(僅在非搜尋模式下執行) if (this.sourceMode === 'folder' && this.sourcePath !== '/' && this.searchQuery === '') { if (this.plugin.settings.folderNoteDisplaySettings === 'hidden') { const currentFolder = this.app.vault.getAbstractFileByPath(this.sourcePath); if (currentFolder instanceof TFolder) { const folderName = currentFolder.name; files = files.filter(f => f.name !== `${folderName}.md`); } } } // 創建 Intersection Observer const observer = new IntersectionObserver((entries, observer) => { const loadEntry = async (entry: IntersectionObserverEntry) => { if (entry.isIntersecting) { const fileEl = entry.target as HTMLElement; const filePath = fileEl.dataset.filePath; if (!filePath) return; const file = this.app.vault.getAbstractFileByPath(filePath); if (!(file instanceof TFile)) return; // 載入預覽內容 let imageUrl: string | null = ''; const contentArea = fileEl.querySelector('.ge-content-area') as Element; if (!contentArea.hasAttribute('data-loaded')) { // 根據檔案類型處理 if (file.extension === 'md') { let summaryLength = this.plugin.settings.summaryLength; if (summaryLength < 50) { summaryLength = 100; this.plugin.settings.summaryLength = 100; void this.plugin.saveSettings(); } // Markdown 檔案顯示內容預覽 const content = await this.app.vault.cachedRead(file); const frontMatterInfo = getFrontMatterInfo(content); let metadata: FrontMatterCache | undefined = undefined; if (frontMatterInfo.exists) { metadata = this.app.metadataCache.getFileCache(file)?.frontmatter; } let pEl: HTMLElement | null = null; if (!this.minMode) { let summaryField = this.plugin.settings.noteSummaryField || 'summary'; let summaryValue = formatFrontmatterValue(getFrontmatterValue(metadata, summaryField)); if (this.sourceMode.startsWith('custom-')) { // 自訂模式下,使用自訂的 fields 來顯示摘要 const mode = this.plugin.settings.customModes.find(m => m.internalName === this.sourceMode); if (mode) { let fields = mode?.fields || ''; // 當有選到子選項 (index >= 0) 而且 options 陣列確實存在 if (this.customOptionIndex >= 0 && mode.options && this.customOptionIndex < mode.options.length) { const option = mode.options[this.customOptionIndex]; fields = option.fields || ''; } // 如果 fields 不為空,則使用它來顯示摘要 if (fields) { const fieldList = fields.split(',').map(f => f.trim()).filter(Boolean); const fieldValues: string[] = []; // 收集所有欄位值,並處理別名("原始欄位|別名") fieldList.forEach(fieldEntry => { const raw = fieldEntry.trim(); // 處理 alias const aliasIdx = raw.lastIndexOf('|'); let fieldKey: string; let labelName: string; if (aliasIdx !== -1) { fieldKey = raw.substring(0, aliasIdx).trim(); labelName = raw.substring(aliasIdx + 1).trim() || fieldKey; } else { fieldKey = raw; labelName = fieldKey; } const rawValue = getFrontmatterValue(metadata, fieldKey); if (rawValue !== undefined && rawValue !== '' && rawValue !== null) { const value = formatFrontmatterValue(rawValue); fieldValues.push(`${labelName}: ${value}`); } }); // 如果有找到任何欄位值,則組合起來 if (fieldValues.length > 0) { summaryValue = fieldValues.join('\n'); } } } } if (summaryValue) { if (!this.sourceMode.startsWith('custom-')) { // Frontmatter 有設定摘要值 pEl = contentArea.createEl('p', { text: summaryValue.trim() }); } else { // custom mode 有設定顯示欄位值 pEl = contentArea.createEl('p', { text: summaryValue.trim(), cls: 'ge-content-area-p-field' }); } } else { // Frontmatter 沒有設定摘要值,則使用內文 let contentWithoutFrontmatter = ''; if (summaryLength < 500) { contentWithoutFrontmatter = content.substring(frontMatterInfo.contentStart).slice(0, 500); } else { contentWithoutFrontmatter = content.substring(frontMatterInfo.contentStart).slice(0, summaryLength + summaryLength); } let contentWithoutMediaLinks = ''; if (this.plugin.settings.showCodeBlocksInSummary) { contentWithoutMediaLinks = contentWithoutFrontmatter; } else { // 刪除 code block contentWithoutMediaLinks = contentWithoutFrontmatter .replace(/```[\s\S]*?```\n/g, '') .replace(/```[\s\S]*$/, ''); } // 刪除註解及連結 contentWithoutMediaLinks = contentWithoutMediaLinks .replace(//g, '') .replace(/!?\[([^\]]*)\]\([^)]+\)|!?\[\[([^\]]+)\]\]/g, (_match: string, p1?: string, p2?: string) => { const rawLinkText = p1 || p2 || ''; if (!rawLinkText) return ''; const wikiLinkParts = p2 ? rawLinkText.split('|') : []; const linkTarget = p2 ? wikiLinkParts[0] : rawLinkText; const linkText = p2 && wikiLinkParts.length > 1 ? wikiLinkParts.slice(1).join('|') : rawLinkText; // 獲取副檔名並檢查是否為圖片或影片 const extension = linkTarget.split('.').pop()?.toLowerCase() || ''; return (IMAGE_EXTENSIONS.has(extension) || VIDEO_EXTENSIONS.has(extension)) ? '' : linkText; }); //把開頭的標題整行刪除 if (contentWithoutMediaLinks.startsWith('# ') || contentWithoutMediaLinks.startsWith('## ') || contentWithoutMediaLinks.startsWith('### ')) { contentWithoutMediaLinks = contentWithoutMediaLinks.split('\n').slice(1).join('\n'); } if (!this.plugin.settings.showCodeBlocksInSummary) { // 不刪除code block的情況下,包含這些特殊符號 contentWithoutMediaLinks = contentWithoutMediaLinks.replace(/[>|\-#*]/g, '').trim(); } // 只取前 summaryLength 個字符作為預覽 const preview = contentWithoutMediaLinks.slice(0, summaryLength) + (contentWithoutMediaLinks.length > summaryLength ? '...' : ''); // 創建預覽內容 pEl = contentArea.createEl('p', { text: preview.trim() }); } } //將預覽文字設定到標題的 title 屬性中 const titleEl = fileEl.querySelector('.ge-title'); if (titleEl) { setTooltip(contentArea as HTMLElement, `${titleEl.textContent}`) } if (frontMatterInfo.exists) { const colorValue = getFrontmatterValue(metadata, 'color'); if (colorValue) { // 檢查是否為 HEX 色值 if (typeof colorValue === 'string' && isHexColor(colorValue)) { // 使用自訂 CSS 變數來設置 HEX 顏色 fileEl.addClass('ge-note-color-custom'); fileEl.setCssProps({ '--ge-note-color-bg': hexToRgba(colorValue, 0.2), '--ge-note-color-border': hexToRgba(colorValue, 0.5), }); // 設置預覽內容文字顏色 if (pEl) { pEl.addClass('ge-note-color-custom-text'); pEl.setCssProps({ '--ge-note-color-text': hexToRgba(colorValue, 0.7), }); } } else if (typeof colorValue === 'string') { // 使用預設的 CSS 類別來設置顏色 fileEl.addClass(`ge-note-color-${colorValue}`); // 設置預覽內容文字顏色 if (pEl) { pEl.addClass(`ge-note-color-${colorValue}-text`); } } } const titleField = this.plugin.settings.noteTitleField || 'title'; const titleValue = formatFrontmatterValue(getFrontmatterValue(metadata, titleField)); if (titleValue) { // 將標題文字設為 frontmatter 的 title if (titleEl) { titleEl.textContent = titleValue; } } const displayValue = getFrontmatterValue(metadata, 'display'); if (displayValue === 'minimized') { // 移除已建立的預覽段落 if (pEl) { pEl.remove(); } // 移除圖片區域(若已存在) const imageAreaEl = fileEl.querySelector('.ge-image-area'); if (imageAreaEl) { imageAreaEl.remove(); } fileEl.addClass('ge-grid-item-full-height'); } else if (displayValue === 'hidden') { // 為隱藏的筆記添加特殊樣式類別 fileEl.addClass('ge-note-hidden'); } // 如果 frontmatter 同時存在 type 與非空的 redirect(視為捷徑檔),將圖示設為 shuffle const redirectType = getFrontmatterValue(metadata, 'type'); const redirectPath = getFrontmatterValue(metadata, 'redirect'); if (redirectType && typeof redirectPath === 'string' && redirectPath.trim() !== '') { const iconContainer = fileEl.querySelector('.ge-icon-container'); if (iconContainer) { setIcon(iconContainer as HTMLElement, 'shuffle'); } } } imageUrl = await findFirstImageInNote(this.app, content); } else { // 其他檔案顯示副檔名 if (!this.minMode) { const extension = file.extension.toLowerCase(); const pEl = contentArea.createEl('p', { cls: 'ge-non-doc-preview' }); const iconSpan = pEl.createSpan({ cls: 'ge-non-doc-icon' }); let iconName = 'file'; if (isImageFile(file)) { iconName = 'image'; iconSpan.addClass('ge-img'); } else if (isVideoFile(file)) { iconName = 'play-circle'; iconSpan.addClass('ge-video'); } else if (isAudioFile(file)) { iconName = 'music'; iconSpan.addClass('ge-audio'); } else if (extension === 'pdf') { iconName = 'paperclip'; iconSpan.addClass('ge-pdf'); } else if (extension === 'canvas') { iconName = 'layout-dashboard'; iconSpan.addClass('ge-canvas'); } else if (extension === 'base') { iconName = 'layout-list'; iconSpan.addClass('ge-base'); } else if (extension === 'zip') { iconName = 'folder-archive'; iconSpan.addClass('ge-zip'); } setIcon(iconSpan, iconName); pEl.createSpan({ text: file.extension.toUpperCase() }); } setTooltip(fileEl, `${file.name}`, { delay: 2000 }) } // 顯示標籤(僅限 Markdown 檔案) if (file.extension === 'md' && this.showNoteTags && !this.minMode) { const fileCache = this.app.metadataCache.getFileCache(file); const displaySetting = getFrontmatterValue(fileCache?.frontmatter, 'display'); // 如果筆記是最小化就直接跳過標籤邏輯 if (displaySetting !== 'minimized') { const allTags = new Set(); // 從 frontmatter 獲取標籤 const frontmatterTags = getFrontmatterValue(fileCache?.frontmatter, 'tags'); // 處理不同的標籤格式 if (typeof frontmatterTags === 'string') { // 如果是字符串,按逗號或空格分割 frontmatterTags.split(/[,\s]+/).filter(tag => tag.trim() !== '') .forEach(tag => allTags.add(tag)); } else if (Array.isArray(frontmatterTags)) { frontmatterTags.forEach(tag => { // 處理陣列中的每個標籤,可能是字符串或包含空格的字符串 if (typeof tag === 'string') { // 檢查標籤是否包含空格(可能是未被正確分割的多個標籤) if (tag.includes(' ')) { // 按空格分割並添加每個子標籤 tag.split(/\s+/).filter(subTag => subTag.trim() !== '') .forEach(subTag => allTags.add(subTag)); } else { allTags.add(tag); } } }); } // 從檔案 cache 中獲取內文標籤 const cacheTags = fileCache?.tags || []; cacheTags.forEach(tagObj => { const tag = tagObj.tag.startsWith('#') ? tagObj.tag.substring(1) : tagObj.tag; allTags.add(tag); }); if (allTags.size > 0) { // 創建標籤容器 const tagsContainer = contentArea.createDiv('ge-tags-container'); // 取得所有標籤 const displayTags = Array.from(allTags); displayTags.forEach(tag => { const tagEl = tagsContainer.createEl('span', { cls: 'ge-tag', text: tag.startsWith('#') ? tag : `#${tag}` }); //添加右鍵選單事件,點擊後開啟選單,點擊選單中的選項後追加標籤到搜尋關鍵字內並重新渲染 tagEl.addEventListener('contextmenu', (e) => { e.preventDefault(); e.stopPropagation(); const tagText = tag.startsWith('#') ? tag : `#${tag}`; const menu = new Menu(); //添加加入搜尋選項 if (!this.searchQuery.includes(tagText)) { menu.addItem(item => item .setTitle(t('add_tag_to_search')) .setIcon('circle-plus') .onClick(() => { const searchQuery = this.searchQuery + ` ${tagText}`; void this.setSource('', '', true, searchQuery); return false; }) ); } // 添加刪除標籤選項 if (this.searchQuery.includes(tagText)) { menu.addItem(item => item .setTitle(t('remove_tag_from_search')) .setIcon('circle-minus') .onClick(() => { const searchQuery = this.searchQuery.replace(tagText, ''); void this.setSource('', '', true, searchQuery); return false; }) ); } menu.showAtPosition({ x: e.clientX, y: e.clientY }); }); // 添加點擊事件,點擊後設置搜尋關鍵字並重新渲染 tagEl.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); // 防止事件冒泡到卡片 const tagText = tag.startsWith('#') ? tag : `#${tag}`; if (this.searchQuery !== tagText) { void this.setSource('', '', true, tagText); } return false; }); }); } } } contentArea.setAttribute('data-loaded', 'true'); } // 載入圖片預覽 if (!this.minMode) { const imageArea = fileEl.querySelector('.ge-image-area'); if (imageArea && !imageArea.hasAttribute('data-loaded')) { // 根據檔案類型處理 if (isImageFile(file)) { // 直接顯示圖片 const img = imageArea.createEl('img'); img.src = this.app.vault.getResourcePath(file); img.draggable = false; imageArea.setAttribute('data-loaded', 'true'); } else if (isVideoFile(file)) { // 根據設定決定是否顯示影片縮圖 if (this.plugin.settings.showVideoThumbnails) { // 顯示影片縮圖 const video = imageArea.createEl('video'); video.src = this.app.vault.getResourcePath(file); } else { // 顯示播放圖示 const videoThumb = imageArea.createDiv('ge-video-thumbnail'); setIcon(videoThumb, 'play-circle'); } imageArea.setAttribute('data-loaded', 'true'); } else if (file.extension === 'md') { // Markdown 檔案尋找內部圖片 if (imageUrl) { const img = imageArea.createEl('img'); img.src = imageUrl; img.draggable = false; imageArea.setAttribute('data-loaded', 'true'); } else { // 如果沒有圖片,移除圖片區域 imageArea.remove(); } } else if (file.extension.toLowerCase() === 'zip') { const zipImgUrl = await getFirstImageFromZip(this.app, file); if (zipImgUrl) { const img = imageArea.createEl('img'); img.src = zipImgUrl; img.draggable = false; imageArea.setAttribute('data-loaded', 'true'); } else { imageArea.remove(); } } else { // 其他檔案類型,移除圖片區域 imageArea.remove(); } } } // 一旦載入完成,就不需要再觀察這個元素 observer.unobserve(fileEl); } }; entries.forEach(entry => { void loadEntry(entry); }); }, { root: container, rootMargin: '50px', // 預先載入視窗外 50px 的內容 threshold: 0.1 }); // 顯示檔案 if (files.length > 0) { // 檢查是否應該顯示日期分隔器 const dateDividerMode = this.plugin.settings.dateDividerMode || 'none'; const sortType = this.sortType; const shouldShowDateDividers = dateDividerMode !== 'none' && (sortType.startsWith('mtime-') || sortType.startsWith('ctime-')) && this.sourceMode !== 'random-note' && this.sourceMode !== 'bookmarks' && this.showDateDividers; let lastDateString = ''; let pinDividerAdded = false; let blankDividerAdded = false; // 每次重新渲染時遞增 token,以便中斷舊批次 const currentToken = ++this.renderToken; // 設定批次渲染狀態 const state: DividerState = { lastDateString: lastDateString, pinDividerAdded: pinDividerAdded, blankDividerAdded: blankDividerAdded }; const paramsBase: FileRenderParams = { container, observer, files, dateDividerMode, sortType, shouldShowDateDividers, state }; const batchSize = 50; let currentIndex = 0; // 建立哨兵元素,用於觸發後續載入 const sentinel = container.createDiv('ge-load-more-sentinel'); let isLoading = false; const loadMore = (entries?: IntersectionObserverEntry[]) => { if (currentToken !== this.renderToken) return; if (entries && entries[0] && !entries[0].isIntersecting) { return; } if (isLoading) return; isLoading = true; // 暫時移除哨兵 sentinel.remove(); let targetMaxEnd = currentIndex + batchSize; if (this.targetFocusPath) { const tIndex = files.findIndex(f => f.path === this.targetFocusPath); if (tIndex >= currentIndex) { targetMaxEnd = Math.max(targetMaxEnd, tIndex + 1); } } const end = Math.min(targetMaxEnd, files.length); const TIME_BUDGET_MS = Platform.isIosApp ? 6 : 16; const renderChunk = () => { if (currentToken !== this.renderToken) return; const startTime = performance.now(); while (currentIndex < end && (performance.now() - startTime) < TIME_BUDGET_MS) { this.processFile(files[currentIndex], paramsBase); currentIndex++; } if (currentIndex < end) { window.requestAnimationFrame(renderChunk); } else { // 這一批次處理完畢 if (this.targetFocusPath) { // 尋找剛剛載入的目標項目並捲動到該位置 const gridItem = Array.from(container.querySelectorAll('.ge-grid-item')).find( item => (item as HTMLElement).dataset.filePath === this.targetFocusPath ) as HTMLElement; if (gridItem) { gridItem.scrollIntoView({ block: 'nearest' }); const itemIndex = this.gridItems.indexOf(gridItem); if (itemIndex >= 0) { this.selectItem(itemIndex); } this.targetFocusPath = null; // 處理完清除目標 } } if (currentIndex < files.length) { // 還有剩餘檔案,將哨兵加回底部 container.appendChild(sentinel); // 主動判斷是否仍在可視範圍內(解決捲動跳轉後 IntersectionObserver 未觸發的問題) window.setTimeout(() => { if (currentToken !== this.renderToken) return; // 由於不一定是在 root container,也可以透過 getBoundingClientRect 簡單測算 const rect = sentinel.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); if (rect.top - containerRect.bottom < 400) { loadMore(); } }, 50); } isLoading = false; } }; renderChunk(); }; const sentinelObserver = new IntersectionObserver(loadMore, { root: container, rootMargin: '400px', // 提早 400px 載入 threshold: 0 }); sentinelObserver.observe(sentinel); // 初始載入 loadMore(); } if (this.plugin.statusBarItem) { this.plugin.statusBarItem.setText(`${files.length} ${t('files')}`); } } // 處理單個檔案渲染 private processFile(file: TFile, params: FileRenderParams): void { const { container, observer, files, dateDividerMode, sortType, shouldShowDateDividers, state } = params; // 如果需要顯示置頂分隔器,且尚未加入,當前檔案為置頂清單之一時插入 if (!state.pinDividerAdded && this.pinnedList.includes(file.name)) { const pinDivider = container.createDiv('ge-pin-divider'); pinDivider.textContent = t('pinned'); state.pinDividerAdded = true; // 針對 iOS 設備進行特殊處理 if (Platform.isIosApp) { pinDivider.addClass('ge-ios-divider'); } } // 插入空白分隔器:當已加入置頂分隔器且尚未加入空白分隔器,且當前檔案不是置頂檔案 if (state.pinDividerAdded && !state.blankDividerAdded && !this.pinnedList.includes(file.name)) { container.createDiv('ge-break'); state.blankDividerAdded = true; } // 日期分隔器 if (shouldShowDateDividers && !this.pinnedList.includes(file.name)) { let timestamp = 0; // 根據排序類型獲取日期時間戳 if (sortType.startsWith('mtime-') || sortType.startsWith('ctime-')) { // 判斷是否以修改日期排序,最近檔案模式使用修改日期排序 const isModifiedTime = sortType.startsWith('mtime-') || this.sourceMode === 'recent-files'; // 檢查是否是 Markdown 文件,且有設定對應的 frontmatter 字段 let frontMatterDate = null; if (file.extension === 'md') { const metadata = this.app.metadataCache.getFileCache(file); if (metadata?.frontmatter) { const fieldSetting = isModifiedTime ? this.plugin.settings.modifiedDateField : this.plugin.settings.createdDateField; const fieldNames = fieldSetting ? fieldSetting.split(',').map(f => f.trim()).filter(Boolean) : []; for (const fieldName of fieldNames) { const dateStr = getFrontmatterValue(metadata.frontmatter, fieldName); if (dateStr) { if (typeof dateStr !== 'string' && typeof dateStr !== 'number' && !(dateStr instanceof Date)) continue; const date = new Date(dateStr); if (!isNaN(date.getTime())) { frontMatterDate = date; break; // 已找到有效日期 } } } } } // 使用 frontmatter 中的日期或檔案的狀態日期 if (frontMatterDate) { timestamp = frontMatterDate.getTime(); } else { timestamp = isModifiedTime ? file.stat.mtime : file.stat.ctime; } } // 創建日期物件並格式化 const fileDate = new Date(timestamp); // 根據日期分隔器模式設定格式化 let currentDateString = ''; if (dateDividerMode === 'year') { // 年分隔器:只顯示年份 currentDateString = fileDate.getFullYear().toString(); } else if (dateDividerMode === 'month') { // 月分隔器:顯示年-月 const year = fileDate.getFullYear(); const month = fileDate.getMonth() + 1; // getMonth() 回傳 0-11 currentDateString = `${year}-${month.toString().padStart(2, '0')}`; } else { // 日分隔器:顯示完整日期(預設行為) currentDateString = fileDate.toLocaleDateString(); } // 如果日期不同於上一個檔案的日期,添加分隔器 if (currentDateString !== state.lastDateString) { state.lastDateString = currentDateString; // 創建日期分隔器 const dateDivider = container.createDiv('ge-date-divider'); dateDivider.textContent = currentDateString; // 針對 iOS 設備進行特殊處理 if (Platform.isIosApp) { dateDivider.addClass('ge-ios-divider'); } } } const fileEl = container.createDiv('ge-grid-item'); this.gridItems.push(fileEl); // 添加到網格項目數組 fileEl.dataset.filePath = file.path; // 如果檔案與父資料夾同名,添加 ge-foldernote 類別 const parentPath = file.parent?.path || ''; const parentName = parentPath.split('/').pop() || ''; if (parentName === file.basename) { fileEl.addClass('ge-foldernote'); } //如果檔案是否處於置頂範圍,添加 ge-pinned 類別 if (this.pinnedList.includes(file.name)) { fileEl.addClass('ge-pinned'); } // 創建左側內容區,包含圖示和標題 const contentArea = fileEl.createDiv('ge-content-area'); // 創建標題容器 const titleContainer = contentArea.createDiv('ge-title-container'); const extension = file.extension.toLowerCase(); // 檢查是否為媒體檔案,如果是則添加 ge-media-card 類別 if (this.cardLayout === 'vertical' && (isImageFile(file) || isVideoFile(file)) && !this.minMode) { fileEl.addClass('ge-media-card'); } // 創建標題(立即載入) const shouldShowExtension = this.minMode && extension !== 'md'; const displayText = shouldShowExtension ? `${file.basename}.${file.extension}` : file.basename; const titleEl = titleContainer.createEl('span', { cls: 'ge-title', text: displayText }); if (this.plugin.settings.multiLineTitle) { titleEl.addClass('ge-multiline-title'); titleContainer.addClass('has-multiline-title'); } // 創建圖片區域,但先不載入圖片 if (!this.minMode) { fileEl.createDiv('ge-image-area'); } // 開始觀察這個元素 observer.observe(fileEl); // 加入滑鼠移入顯示的右上角圓形按鈕(僅針對可在網格中顯示筆記的檔案) // 位置與顯示由 CSS 控制(.ge-hover-open-note) // 當設定為「直接在網格中顯示筆記」時,不顯示此按鈕 /* if (file.extension === 'md' && !this.plugin.settings.showNoteInGrid) { // 確保容器可做為定位參考 fileEl.style.position = fileEl.style.position || 'relative'; const quickBtn = fileEl.createDiv({ cls: 'ge-hover-open-note' }); setIcon(quickBtn, 'maximize-2'); quickBtn.addEventListener('click', (e) => { e.stopPropagation(); e.preventDefault(); // 如果是捷徑檔案,遵循捷徑開啟邏輯;否則在網格中顯示筆記 if (!this.openShortcutFile(file)) { void this.showNoteInGrid(file); } }); // 阻止滑鼠事件影響拖曳或選取 quickBtn.addEventListener('mousedown', (e) => { e.stopPropagation(); }); }*/ // 滑鼠懸停在項目上時,按 Ctrl 鍵直接顯示筆記或 ZIP 圖片網格 if (Platform.isDesktop && (file.extension === 'md' || file.extension === 'zip') && !this.plugin.settings.showNoteInGrid) { let triggeredInHover = false; let isHovering = false; let isMouseDown = false; // 追蹤滑鼠按下狀態 let keydownListener: ((e: KeyboardEvent) => void) | null = null; const trigger = () => { if (triggeredInHover || isMouseDown) return; // 如果滑鼠按下則不觸發 triggeredInHover = true; if (!this.openShortcutFile(file)) { if (file.extension === 'md') { void this.showNoteInGrid(file); } else if (file.extension === 'zip') { void this.showZipInGrid(file); } } }; const onKeyDown = (e: KeyboardEvent) => { const target = e.target as HTMLElement | null; const isEditingText = target?.closest('input, textarea, select, [contenteditable="true"]'); // 只有在滑鼠確實懸停在此項目上且單獨按下 Ctrl 時才觸發 // 避免在篩選輸入框使用 Ctrl+C / Ctrl+V 等快捷鍵時誤開筆記 // 且滑鼠沒有按下(避免干擾 Ctrl+click) // 並且當前 GridView 必須是活動視圖 if (isHovering && e.key === 'Control' && !isEditingText && !isMouseDown && this.app.workspace.getActiveViewOfType(GridView) === this) { // 短暫延遲以確保不是 Ctrl+click 操作 window.setTimeout(() => { if (isHovering && !triggeredInHover && !isMouseDown && this.app.workspace.getActiveViewOfType(GridView) === this) { trigger(); } }, 300); } }; const onMouseDown = () => { isMouseDown = true; triggeredInHover = true; // 防止在點擊過程中觸發 hover 功能 }; const onMouseUp = () => { isMouseDown = false; // 重置觸發狀態,但稍微延遲以避免立即重新觸發 window.setTimeout(() => { if (isHovering) { triggeredInHover = false; } }, 50); }; const onMouseEnter = () => { triggeredInHover = false; isHovering = true; if (!keydownListener) { keydownListener = onKeyDown; activeDocument.addEventListener('keydown', keydownListener, { capture: true }); } }; const onMouseLeave = () => { isHovering = false; isMouseDown = false; if (keydownListener) { activeDocument.removeEventListener('keydown', keydownListener, { capture: true }); keydownListener = null; } triggeredInHover = false; }; fileEl.addEventListener('mouseenter', onMouseEnter); fileEl.addEventListener('mouseleave', onMouseLeave); fileEl.addEventListener('mousedown', onMouseDown); fileEl.addEventListener('mouseup', onMouseUp); // 添加清理函數到數組中 this.eventCleanupFunctions.push(() => { if (keydownListener) { activeDocument.removeEventListener('keydown', keydownListener, { capture: true }); } fileEl.removeEventListener('mouseenter', onMouseEnter); fileEl.removeEventListener('mouseleave', onMouseLeave); fileEl.removeEventListener('mousedown', onMouseDown); fileEl.removeEventListener('mouseup', onMouseUp); }); } // 點擊時開啟檔案 fileEl.addEventListener('click', (event) => { // 獲取項目索引 const index = this.gridItems.indexOf(fileEl); if (index < 0) return; if (event.ctrlKey || event.metaKey) { if (isMediaFile(file)) { // 開啟媒體檔案 if (isAudioFile(file)) { FloatingAudioPlayer.open(this.app, file); } else { this.openMediaFile(file, files); } } else { if (event.ctrlKey && event.altKey) { // Ctrl+Alt:開啟在分割視窗 void this.app.workspace.getLeaf('split').openFile(file); } else { // 開啟文件檔案到新分頁 void this.app.workspace.getLeaf(true).openFile(file); } } event.preventDefault(); return; } else if (event.shiftKey) { // Shift 鍵:範圍選擇 this.handleRangeSelection(index); this.hasKeyboardFocus = true; event.preventDefault(); return; } else if (event.altKey) { // Alt 鍵或設定為預設時:網格視圖中直接顯示筆記 this.selectItem(index, true); this.hasKeyboardFocus = true; event.preventDefault(); return; } else if (this.plugin.settings.showNoteInGrid) { this.selectItem(index); this.hasKeyboardFocus = true; if (isMediaFile(file)) { // 媒體檔案:正常開啟 if (isAudioFile(file)) { FloatingAudioPlayer.open(this.app, file); } else { this.openMediaFile(file, files); } } else if (file.extension === 'pdf' || file.extension === 'canvas' || file.extension === 'base') { void this.getLeafByMode(file).openFile(file); } else { // 非媒體檔案 // 如果是捷徑檔案,則開啟捷徑,否則在網格視圖中直接顯示筆記 if (!this.openShortcutFile(file)) { if(file.extension === 'md') { void this.showNoteInGrid(file); // 在網格視圖中直接顯示筆記 } else if (file.extension === 'zip') { void this.showZipInGrid(file); // 在網格視圖中直接顯示 ZIP } else { void this.getLeafByMode(file).openFile(file); } } } event.preventDefault(); return; } else { // 一般點擊:選中單個項目並開啟 this.selectItem(index); this.hasKeyboardFocus = true; // 根據檔案類型處理點擊事件 if (isMediaFile(file)) { // 開啟媒體檔案 if (isAudioFile(file)) { FloatingAudioPlayer.open(this.app, file); } else { this.openMediaFile(file, files); } } else { // 非媒體檔案 // 如果是捷徑檔案,則開啟捷徑,否則正常開啟檔案 if (!this.openShortcutFile(file)) { // 非捷徑就正常開啟檔案 const leaf = this.getLeafByMode(file); if (this.searchQuery) { void (async () => { const content = await this.app.vault.cachedRead(file); const searchQuery = this.searchQuery; const lowerContent = content.toLowerCase(); let idx = -1; let lineNumber = 0; // 1. 先嘗試完整比對(不分大小寫) idx = lowerContent.indexOf(searchQuery.toLowerCase()); // 2. 若找不到,嘗試拆開關鍵字搜尋 if (idx === -1 && searchQuery.includes(' ')) { const keywords = searchQuery.split(/\s+/).filter(k => k.trim() !== ''); if (keywords.length > 1) { // 找第一個出現的關鍵字 for (const keyword of keywords) { const keywordIdx = lowerContent.indexOf(keyword.toLowerCase()); if (keywordIdx !== -1) { idx = keywordIdx; break; } } } } if (idx !== -1) { lineNumber = content.substring(0, idx).split('\n').length - 1; await this.openNoteFile(leaf, file, { eState: { line: lineNumber } }); (this.app as AppWithCommands).commands?.executeCommandById?.('editor:focus'); return; } // 若都找不到關鍵字,直接開檔 await this.openNoteFile(leaf, file); })(); } else { void this.openNoteFile(leaf, file); } } } } }); // 避免中鍵點擊會自動滾動頁面 fileEl.addEventListener('mousedown', (event) => { if (event.button === 1) { event.preventDefault(); } }); fileEl.addEventListener('mouseup', (event) => { if (event.button === 1) { event.preventDefault(); if (!isMediaFile(file)) { void this.app.workspace.getLeaf(true).openFile(file); } } }); if (Platform.isDesktop) { // 添加拖曳功能 fileEl.setAttribute('draggable', 'true'); fileEl.addEventListener('dragstart', (event) => { // 獲取項目索引 const index = this.gridItems.indexOf(fileEl); if (index >= 0) { // 如果項目未被選中,則選中它 if (!this.selectedItems.has(index)) { this.selectItem(index); } } // 獲取選中的檔案 const selectedFiles = this.getSelectedFiles(); let drag_filename = ''; if (selectedFiles.length > 1) { // 設定 text/plain const mdList = selectedFiles .map(f => { const link = this.app.fileManager.generateMarkdownLink(f, ''); return isMediaFile(f) ? `!${link}` : link; }) .join('\n'); event.dataTransfer?.setData('text/plain', mdList); // 兼容舊版:提供 markdown 連結與舊自定義 MIME,供 main.ts 使用 event.dataTransfer?.setData('application/obsidian-grid-explorer-files', JSON.stringify(selectedFiles.map(f => f.path))); drag_filename = `${selectedFiles.length} ${t('files')}`; } else { // 設定 text/plain let mdLink = this.app.fileManager.generateMarkdownLink(file, ''); if (isMediaFile(file)) { mdLink = `!${mdLink}`; } event.dataTransfer?.setData('text/plain', mdLink); // 兼容舊版:提供 markdown 連結與舊自定義 MIME,供 main.ts 使用 event.dataTransfer?.setData('application/obsidian-grid-explorer-files', JSON.stringify([file.path])); drag_filename = file.basename; } const dragImage = activeDocument.createElement('div'); dragImage.className = 'ge-custom-drag-preview'; dragImage.textContent = drag_filename; // 將元素暫時加入 DOM activeDocument.body.appendChild(dragImage); // 設定拖曳圖示 event.dataTransfer!.setDragImage(dragImage, 20, 20); // 延遲移除元素(讓拖曳圖示正常顯示) window.setTimeout(() => { activeDocument.body.removeChild(dragImage); }, 0); // 設定拖曳效果 event.dataTransfer!.effectAllowed = 'all'; // 添加拖曳中的視覺效果 fileEl.addClass('ge-dragging'); }); fileEl.addEventListener('dragend', () => { // 移除拖曳中的視覺效果 fileEl.removeClass('ge-dragging'); }); } // 添加右鍵選單 fileEl.addEventListener('contextmenu', (event) => { event.preventDefault(); const menu = new Menu(); // 獲取項目索引 const index = this.gridItems.indexOf(fileEl); if (index >= 0) { // 如果項目未被選中,則選中它 if (!this.selectedItems.has(index)) { this.selectItem(index); } } // 獲取選中的檔案 const selectedFiles = this.getSelectedFiles(); if (selectedFiles.length > 1) { // 多個檔案被選中,使用 files-menu this.app.workspace.trigger('files-menu', menu, selectedFiles); // 檢查是否所有選中的檔案都是 md 檔案 const allMdFiles = selectedFiles.every(file => file.extension === 'md'); if (allMdFiles) { menu.addItem((item) => { item .setTitle(t('set_note_attribute')) .setIcon("palette") .onClick(() => { showNoteSettingsModal(this.app, this.plugin, selectedFiles); }); }); } } else { this.app.workspace.trigger('file-menu', menu, file); } // 新增在新分頁開啟選項 menu.addItem((item) => { item .setTitle(t('open_in_new_tab')) .setIcon("external-link") .setSection?.("open") .onClick(() => { if (selectedFiles.length > 1) { // 如果多個檔案被選中,開啟所有檔案 for (const f of selectedFiles) { void this.app.workspace.getLeaf(true).openFile(f); } } else { void this.app.workspace.getLeaf(true).openFile(file); } }); }); // 加入暫存區選項 (僅在行動裝置上顯示) if (Platform.isMobile) { menu.addItem((item) => { item .setTitle(t('add_to_stash')) .setIcon("archive") .onClick(() => { this.addFilesToStash(selectedFiles); }); }); } // 複製選項 menu.addItem((item) => { item .setTitle(t("make_a_copy")) .setIcon("copy") .setSection?.("action") .onClick(async () => { for (const f of selectedFiles) { const parentPath = f.parent ? f.parent.path : ""; let copyPath = ""; if (parentPath && parentPath !== "/") { copyPath = `${parentPath}/${f.basename} 1.${f.extension}`; } else { copyPath = `${f.basename} 1.${f.extension}`; } let counter = 1; while (this.app.vault.getAbstractFileByPath(copyPath)) { counter++; if (parentPath && parentPath !== "/") { copyPath = `${parentPath}/${f.basename} ${counter}.${f.extension}`; } else { copyPath = `${f.basename} ${counter}.${f.extension}`; } } try { await this.app.vault.copy(f, copyPath); } catch (err) { console.error("Failed to copy file:", err); } } }); }); // 刪除選項 menu.addItem((item) => { (item as MenuItemWithWarning).setWarning(true); item .setTitle(t('delete_note')) .setIcon("trash") .onClick(() => { void (async () => { if (selectedFiles.length > 1) { // 刪除多個檔案 for (const f of selectedFiles) { await this.app.fileManager.trashFile(f); } } else { // 刪除單個檔案 await this.app.fileManager.trashFile(file); } // 清除選中狀態 this.clearSelection(); })(); }); }); menu.showAtMouseEvent(event); }); } // 將檔案添加到 ExplorerView 的暫存區 private addFilesToStash(files: TFile[]) { // 獲取當前活動的 ExplorerView const explorerLeaves = this.app.workspace.getLeavesOfType(EXPLORER_VIEW_TYPE); if (explorerLeaves.length === 0) { new Notice('找不到 explorer 視圖'); return; } // 使用第一個找到的 ExplorerView const explorerView = explorerLeaves[0].view as ExplorerView; if (!explorerView) { new Notice('無法訪問 explorer 視圖'); return; } // 將檔案路徑轉換為字串陣列 const filePaths = files.map(file => file.path); // 調用 ExplorerView 的 addToStash 方法 (explorerView as unknown as ExplorerViewActions).addToStash(filePaths); // 強制立即重新渲染 ExplorerView 以確保畫面更新 (explorerView as unknown as ExplorerViewActions).refresh(); // 顯示成功通知 new Notice(t('added_to_stash')); } onPaneMenu(menu: Menu, source: string) { menu.addItem(item => { item .setTitle(t('hide_header_elements')) .setIcon("archive-restore") .setChecked(this.hideHeaderElements) .onClick(() => { this.hideHeaderElements = !this.hideHeaderElements; this.app.workspace.requestSaveLayout(); void this.render(); }); }); menu.addItem((item) => { item .setTitle(t('reselect')) .setIcon("grid") .onClick(() => { showFolderSelectionModal(this.app, this.plugin, this); }); }); menu.addItem((item) => { item .setTitle(t('refresh')) .setIcon("refresh-cw") .onClick(() => { void this.render(); }); }); } // 清除選中狀態 clearSelection() { this.gridItems.forEach(item => { item.removeClass('ge-selected-item'); }); this.selectedItemIndex = -1; this.selectedItems.clear(); } // 選中指定索引的項目 selectItem(index: number, multiSelect = false) { // 如果不是多選模式,先清除所有項目的選中狀態 if (!multiSelect) { this.gridItems.forEach(item => { item.removeClass('ge-selected-item'); }); this.selectedItems.clear(); } // 確保索引在有效範圍內 if (index >= 0 && index < this.gridItems.length) { this.selectedItemIndex = index; const selectedItem = this.gridItems[index]; // 如果是多選模式且項目已被選中,則取消選中 if (multiSelect && this.selectedItems.has(index)) { selectedItem.removeClass('ge-selected-item'); this.selectedItems.delete(index); // 如果取消選中後沒有選中項目,則設置 selectedItemIndex 為 -1 if (this.selectedItems.size === 0) { this.selectedItemIndex = -1; } else { // 否則設置為最後一個選中項目 this.selectedItemIndex = Array.from(this.selectedItems).pop() || -1; } } else { // 否則選中項目 selectedItem.addClass('ge-selected-item'); this.selectedItems.add(index); } // 確保選中的項目在視圖中可見 selectedItem.scrollIntoView({ block: 'nearest' }); } } // 處理範圍選擇(Shift 鍵) handleRangeSelection(index: number) { if (this.selectedItemIndex === -1) { // 如果沒有已選中的項目,直接選中當前項目 this.selectItem(index); return; } // 計算範圍的起始和結束索引 const startIndex = Math.min(this.selectedItemIndex, index); const endIndex = Math.max(this.selectedItemIndex, index); // 清除現有選擇 this.gridItems.forEach(item => { item.removeClass('ge-selected-item'); }); this.selectedItems.clear(); // 選中範圍內的所有項目 for (let i = startIndex; i <= endIndex; i++) { this.gridItems[i].addClass('ge-selected-item'); this.selectedItems.add(i); } // 更新當前選中索引 this.selectedItemIndex = index; } // 獲取所有選中項目的檔案 getSelectedFiles(): TFile[] { const files: TFile[] = []; this.selectedItems.forEach(index => { const fileEl = this.gridItems[index]; const filePath = fileEl.dataset.filePath; if (filePath) { const file = this.app.vault.getAbstractFileByPath(filePath); if (file instanceof TFile) { files.push(file); } } }); return files; } // 開啟媒體檔案 openMediaFile(file: TFile, mediaFiles?: TFile[]) { // 如果沒有傳入媒體檔案列表,則獲取 const getMediaFilesPromise = mediaFiles ? Promise.resolve(mediaFiles.filter(f => isMediaFile(f))) : getFiles(this, this.includeMedia).then(allFiles => allFiles.filter(f => isMediaFile(f))); void getMediaFilesPromise.then(filteredMediaFiles => { // 找到當前檔案在媒體檔案列表中的索引 const currentIndex = filteredMediaFiles.findIndex(f => f.path === file.path); if (currentIndex === -1) return; // 使用 MediaModal 開啟媒體檔案,並傳入 this 作為 gridView 參數 const mediaModal = new MediaModal(this.app, file, filteredMediaFiles, this); mediaModal.open(); }); } // 開啟筆記檔案,必要時關閉目前的網格視圖 private async openNoteFile(leaf: WorkspaceLeaf, file: TFile, openState?: Parameters[1]): Promise { await leaf.openFile(file, openState); if (this.plugin.settings.closeGridViewOnOpenNote && leaf !== this.leaf) { this.leaf.detach(); } } // 根據 openNoteLayout 設定獲取對應的 leaf getLeafByMode(file?: TFile): WorkspaceLeaf { const mode = this.plugin.settings.openNoteLayout; switch (mode) { case 'newTab': // 如果提供了檔案,先檢查是否已經在某個分頁中開啟 if (file) { // 獲取所有 leaves,不限定類型 const allLeaves = this.app.workspace.getLeavesOfType('markdown') .concat(this.app.workspace.getLeavesOfType('pdf')) .concat(this.app.workspace.getLeavesOfType('canvas')) .concat(this.app.workspace.getLeavesOfType('bases')) .concat(this.app.workspace.getLeavesOfType('excalidraw'));; for (const leaf of allLeaves) { const viewState = leaf.getViewState(); if (viewState.state?.file === file.path) { // 找到已開啟的分頁,切換焦點到該分頁 this.app.workspace.setActiveLeaf(leaf, { focus: true }); return leaf; } } } // 沒有找到已開啟的分頁,開新分頁 return this.app.workspace.getLeaf('tab'); case 'split': { // 檢查是否已經有 split 視圖存在 const mainEntry = this.app.workspace.rootSplit as WorkspaceSplitWithChildren | undefined; if ((mainEntry?.children?.length ?? 0) > 1) { // 如果已經有 split,直接在現有的 split 中開啟 return this.app.workspace.getLeaf(false); } else { // 如果沒有 split,創建新的 split return this.app.workspace.getLeaf('split'); } } case 'newWindow': return this.app.workspace.getLeaf('window'); case 'default': default: return this.app.workspace.getLeaf(); } } // 開啟捷徑檔案 openShortcutFile(file: TFile): boolean { const fileCache = this.app.metadataCache.getFileCache(file); const frontmatter: ShortcutFrontmatter | undefined = fileCache?.frontmatter; if (!frontmatter) return false; const redirectType = typeof frontmatter.type === 'string' ? frontmatter.type : ''; const redirectPath = typeof frontmatter.redirect === 'string' ? frontmatter.redirect : ''; const cardLayout = frontmatter.cardLayout === 'horizontal' || frontmatter.cardLayout === 'vertical' ? frontmatter.cardLayout : undefined; if (redirectType && typeof redirectPath === 'string' && redirectPath.trim() !== '') { let target; if (redirectType === 'file') { // 支援 ![[...]](嵌入)與 [[file|alias]](別名)格式 const trimmed = redirectPath.trim(); const isEmbed = trimmed.startsWith('!'); const wikilink = isEmbed ? trimmed.substring(1) : trimmed; // 去除前置 '!' if (wikilink.startsWith('[[') && wikilink.endsWith(']]')) { let linkInner = wikilink.slice(2, -2).trim(); // 去除別名部分,例如 "path|alias" -> "path" const pipeIdx = linkInner.indexOf('|'); if (pipeIdx !== -1) linkInner = linkInner.substring(0, pipeIdx).trim(); target = this.app.metadataCache.getFirstLinkpathDest(linkInner, file.path); } else { target = this.app.vault.getAbstractFileByPath(normalizePath(redirectPath)); } if (!target) return false; if (target instanceof TFile) { void this.getLeafByMode(target).openFile(target); return true; } else { new Notice(`${t('target_not_found')}: ${redirectPath}`); } } else if (redirectType === 'folder') { target = this.app.vault.getAbstractFileByPath(normalizePath(redirectPath)); if (!target) return false; // 判斷redirectPath是否為資料夾 if (target instanceof TFolder) { void this.setSource('folder', redirectPath); this.clearSelection(); window.requestAnimationFrame(() => { if (cardLayout && cardLayout !== this.cardLayout) { this.cardLayout = cardLayout; void this.render(); } }); return true; } else { new Notice(`${t('target_not_found')}: ${redirectPath}`); } } else if (redirectType === 'mode') { // 判斷redirectPath是否為模式 void this.setSource(redirectPath); this.clearSelection(); window.requestAnimationFrame(() => { if (cardLayout && cardLayout !== this.cardLayout) { this.cardLayout = cardLayout; void this.render(); } }); return true; } else if (redirectType === 'search') { const searchCurrentLocationOnly = frontmatter.searchCurrentLocationOnly === true; const searchFilesNameOnly = frontmatter.searchFilesNameOnly === true; const searchMediaFiles = frontmatter.searchMediaFiles === true; void this.setSource('', '', true, redirectPath, searchCurrentLocationOnly, searchFilesNameOnly, searchMediaFiles); this.clearSelection(); return true; } else if (redirectType === 'uri') { // 檢查是否為 http/https 或 obsidian:// 協議 if (redirectPath.startsWith('http://') || redirectPath.startsWith('https://') || redirectPath.startsWith('obsidian://') || redirectPath.startsWith('file://')) { // 使用 window.open 打開網址或 obsidian 協議 window.open(redirectPath, '_blank'); return true; } else { new Notice(`${t('target_not_found')}: ${redirectPath}`); } } else { new Notice(`${t('target_not_found')}: ${redirectPath}`); } } return false; } // 在網格視圖中直接顯示筆記 async showNoteInGrid(file: TFile) { return this.previewManager.showNoteInGrid(file); } // 隱藏筆記顯示 hideNoteInGrid() { this.previewManager.hideNoteInGrid(); } // 在網格視圖中直接顯示 ZIP 圖片網格 async showZipInGrid(file: TFile) { return this.previewManager.showZipInGrid(file); } // 開啟 ZIP 內部圖片的 MediaModal openZipMediaModal(index: number, gridEl: HTMLElement) { this.previewManager.openZipMediaModal(index, gridEl); } // 隱藏 ZIP 顯示 hideZipInGrid() { this.previewManager.hideZipInGrid(); } selectZipItem(idx: number, gridEl: HTMLElement) { this.previewManager.selectZipItem(idx, gridEl); } // 保存視圖狀態 getState() { return { type: 'grid-view', state: { sourceMode: this.sourceMode, sourcePath: this.sourcePath, baseSortType: this.baseSortType, sortType: this.sortType, searchQuery: this.searchQuery, searchCurrentLocationOnly: this.searchCurrentLocationOnly, searchFilesNameOnly: this.searchFilesNameOnly, searchMediaFiles: this.searchMediaFiles, fileNameFilterQuery: this.fileNameFilterQuery, includeMedia: this.includeMedia, baseMinMode: this.baseMinMode, minMode: this.minMode, showIgnoredItems: this.showIgnoredItems, baseCardLayout: this.baseCardLayout, cardLayout: this.cardLayout, hideHeaderElements: this.hideHeaderElements, showFileNameFilter: this.showFileNameFilter, baseShowDateDividers: this.baseShowDateDividers, showDateDividers: this.showDateDividers, showNoteTags: this.showNoteTags, recentSources: this.recentSources, futureSources: this.futureSources, bookmarkGroupId: this.bookmarkGroupId, } }; } // 讀取視圖狀態 async setState(state: GridViewState): Promise { if (state?.state) { this.sourceMode = state.state.sourceMode || 'folder'; this.sourcePath = state.state.sourcePath || '/'; this.baseSortType = state.state.baseSortType || this.plugin.settings.defaultSortType; this.sortType = state.state.sortType || this.baseSortType; this.searchQuery = state.state.searchQuery || ''; this.searchCurrentLocationOnly = state.state.searchCurrentLocationOnly ?? false; this.searchFilesNameOnly = state.state.searchFilesNameOnly ?? false; this.searchMediaFiles = state.state.searchMediaFiles ?? false; this.fileNameFilterQuery = state.state.fileNameFilterQuery ?? ''; this.includeMedia = state.state.includeMedia ?? false; this.baseMinMode = state.state.baseMinMode ?? state.state.minMode ?? false; this.minMode = state.state.minMode ?? this.baseMinMode; this.showIgnoredItems = state.state.showIgnoredItems ?? false; this.baseCardLayout = state.state.baseCardLayout ?? 'horizontal'; this.cardLayout = state.state.cardLayout ?? this.baseCardLayout; this.hideHeaderElements = state.state.hideHeaderElements ?? false; this.showFileNameFilter = state.state.showFileNameFilter ?? this.plugin.settings.showFileNameFilter; this.baseShowDateDividers = state.state.baseShowDateDividers ?? state.state.showDateDividers ?? (this.plugin.settings.dateDividerMode !== 'none'); this.showDateDividers = state.state.showDateDividers ?? this.baseShowDateDividers; this.showNoteTags = state.state.showNoteTags ?? this.plugin.settings.showNoteTags; this.recentSources = state.state.recentSources ?? []; this.futureSources = state.state.futureSources ?? []; this.bookmarkGroupId = state.state.bookmarkGroupId ?? 'all'; await this.render(); } } }