From 970f27fe00cd3e562d8f0fc4b968eea0059b1895 Mon Sep 17 00:00:00 2001 From: Devon22 Date: Wed, 17 Jun 2026 17:32:56 +0800 Subject: [PATCH] 3.4.7 --- manifest.json | 2 +- package-lock.json | 4 +- package.json | 2 +- src/FileWatcher.ts | 8 +- src/GridView.ts | 4 +- src/modal/mediaModal.ts | 220 ++++++++++++++++++++++++++++++++++++-- src/renderModePath.ts | 22 +++- src/translations/en.ts | 1 + src/translations/ja.ts | 1 + src/translations/ko.ts | 1 + src/translations/ru.ts | 1 + src/translations/uk.ts | 1 + src/translations/zh-TW.ts | 1 + src/translations/zh.ts | 1 + src/utils/fileUtils.ts | 11 +- 15 files changed, 257 insertions(+), 23 deletions(-) diff --git a/manifest.json b/manifest.json index 7d1f1af..e9f19fa 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "gridexplorer", "name": "GridExplorer", - "version": "3.4.6", + "version": "3.4.7", "minAppVersion": "1.8.7", "description": "Browse note files in a grid view.", "author": "Devon22", diff --git a/package-lock.json b/package-lock.json index ee04c72..7a1d59c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gridexplorer", - "version": "3.4.6", + "version": "3.4.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gridexplorer", - "version": "3.4.6", + "version": "3.4.7", "license": "MIT", "dependencies": { "jszip": "^3.10.1" diff --git a/package.json b/package.json index ab3f20d..ecb5b36 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gridexplorer", - "version": "3.4.6", + "version": "3.4.7", "description": "Browse note files in a grid view.", "main": "main.js", "scripts": { diff --git a/src/FileWatcher.ts b/src/FileWatcher.ts index dd08158..6b3f6ad 100644 --- a/src/FileWatcher.ts +++ b/src/FileWatcher.ts @@ -27,7 +27,9 @@ export class FileWatcher { this.app.vault.on('modify', (file) => { if (file instanceof TFile) { if (this.gridView.sourceMode === 'recent-files') { - if(isDocumentFile(file) || (isMediaFile(file) && this.gridView.includeMedia)) { + const isDocAllowed = isDocumentFile(file) && this.gridView.includeMedia !== 'media-only'; + const isMediaAllowed = isMediaFile(file) && !!this.gridView.includeMedia; + if (isDocAllowed || isMediaAllowed) { this.scheduleRender(5000); } } @@ -46,7 +48,9 @@ export class FileWatcher { if(this.gridView.sourceMode === 'random-note') { return; } else if (this.gridView.sourceMode === 'recent-files') { - if(isDocumentFile(file) || (isMediaFile(file) && this.gridView.includeMedia)) { + const isDocAllowed = isDocumentFile(file) && this.gridView.includeMedia !== 'media-only'; + const isMediaAllowed = isMediaFile(file) && !!this.gridView.includeMedia; + if (isDocAllowed || isMediaAllowed) { this.scheduleRender(2000); } } else if (this.gridView.sourceMode === 'folder') { diff --git a/src/GridView.ts b/src/GridView.ts index f1fbf72..9bc8705 100644 --- a/src/GridView.ts +++ b/src/GridView.ts @@ -95,7 +95,7 @@ interface GridViewStateData { searchFilesNameOnly?: boolean; searchMediaFiles?: boolean; fileNameFilterQuery?: string; - includeMedia?: boolean; + includeMedia?: boolean | 'media-only'; minMode?: boolean; showIgnoredItems?: boolean; baseCardLayout?: 'horizontal' | 'vertical'; @@ -155,7 +155,7 @@ export class GridView extends ItemView { searchFilesNameOnly: boolean = false; // 是否只搜尋筆記名稱 searchMediaFiles: boolean = false; // 是否搜尋媒體檔案 fileNameFilterQuery: string = ''; // 目前列表的檔名篩選關鍵字 - includeMedia: boolean = false; // 是否包含媒體檔案 + includeMedia: boolean | 'media-only' = false; // 是否包含媒體檔案 selectedItemIndex: number = -1; // 當前選中的項目索引 selectedItems: Set = new Set(); // 存儲多選的項目索引 gridItems: HTMLElement[] = []; // 存儲所有網格項目的引用 diff --git a/src/modal/mediaModal.ts b/src/modal/mediaModal.ts index d976f0d..cc1c5f2 100644 --- a/src/modal/mediaModal.ts +++ b/src/modal/mediaModal.ts @@ -78,6 +78,16 @@ export class MediaModal extends Modal { } onOpen() { + const appWithPlugins = this.app as { + plugins?: { + plugins?: Record; + }; + }; + const plugin = appWithPlugins.plugins?.plugins?.['obsidian-gridexplorer']; + if (plugin) { + plugin.activeMediaModal = this; + } + const { contentEl } = this; // 設置 modal 樣式為全螢幕 @@ -183,6 +193,16 @@ export class MediaModal extends Modal { } onClose() { + const appWithPlugins = this.app as { + plugins?: { + plugins?: Record; + }; + }; + const plugin = appWithPlugins.plugins?.plugins?.['obsidian-gridexplorer']; + if (plugin && plugin.activeMediaModal === this) { + plugin.activeMediaModal = null; + } + const { contentEl } = this; contentEl.empty(); @@ -266,13 +286,179 @@ export class MediaModal extends Modal { mediaContainer.appendChild(img); - // 圖片點擊事件(放大/縮小) - if (Platform.isMobile) { - img.addEventListener('dblclick', (event) => { - event.stopPropagation(); - this.toggleImageZoom(img, event); - }); - } else { + // 取得與更新圖片初始大小並綁定手勢事件 + let initialWidth = 0; + let initialHeight = 0; + let currentScale = 1; + let isPinching = false; + let pinchStartDistance = 0; + let pinchStartScale = 1; + let pinchStartCenterX = 0; + let pinchStartCenterY = 0; + let pinchStartScrollLeft = 0; + let pinchStartScrollTop = 0; + let lastTapTime = 0; + + let pinchRatioX = 0.5; + let pinchRatioY = 0.5; + + const initImageDimensions = () => { + initialWidth = img.offsetWidth || img.clientWidth; + initialHeight = img.offsetHeight || img.clientHeight; + currentScale = 1; + }; + + img.onload = () => { + // 移除舊 of 媒體元素 + if (this.currentMediaElement) { + this.currentMediaElement.remove(); + } + this.currentMediaElement = img; + // 設置圖片樣式,預設滿屏顯示 + this.resetImageStyles(img); + // 顯示新圖片 + img.removeClass('ge-hidden'); + initImageDimensions(); + }; + + if (img.complete) { + initImageDimensions(); + } + + // 行動裝置手勢支援 (Double Tap & Pinch Zoom) + img.addEventListener('touchstart', (e) => { + if (e.touches.length === 1) { + // 行動裝置雙擊 (Double Tap) 偵測 + const now = Date.now(); + if (now - lastTapTime < 300) { + e.preventDefault(); + e.stopPropagation(); + this.toggleImageZoom(img); + lastTapTime = 0; + return; + } + lastTapTime = now; + } else if (e.touches.length === 2) { + // 雙指捏合縮放 (Pinch Zoom) 開始 + e.preventDefault(); + isPinching = true; + const t1 = e.touches[0]; + const t2 = e.touches[1]; + pinchStartDistance = Math.hypot(t1.clientX - t2.clientX, t1.clientY - t2.clientY); + pinchStartCenterX = (t1.clientX + t2.clientX) / 2; + pinchStartCenterY = (t1.clientY + t2.clientY) / 2; + + if (initialWidth > 0) { + pinchStartScale = img.offsetWidth / initialWidth; + } else { + pinchStartScale = 1; + } + + const mediaView = this.contentEl.querySelector('.ge-media-view'); + if (mediaView) { + pinchStartScrollLeft = mediaView.scrollLeft; + pinchStartScrollTop = mediaView.scrollTop; + + const viewRect = mediaView.getBoundingClientRect(); + const relativeCenterX = pinchStartCenterX - viewRect.left; + const relativeCenterY = pinchStartCenterY - viewRect.top; + + const startWidth = img.offsetWidth || initialWidth; + const startHeight = img.offsetHeight || initialHeight; + + const imgRect = img.getBoundingClientRect(); + const imgLeftInView = imgRect.left - viewRect.left + pinchStartScrollLeft; + const imgTopInView = imgRect.top - viewRect.top + pinchStartScrollTop; + + pinchRatioX = startWidth > 0 ? (pinchStartScrollLeft + relativeCenterX - imgLeftInView) / startWidth : 0.5; + pinchRatioY = startHeight > 0 ? (pinchStartScrollTop + relativeCenterY - imgTopInView) / startHeight : 0.5; + } + } + }, { passive: false }); + + img.addEventListener('touchmove', (e) => { + if (isPinching && e.touches.length === 2) { + e.preventDefault(); + const t1 = e.touches[0]; + const t2 = e.touches[1]; + const dist = Math.hypot(t1.clientX - t2.clientX, t1.clientY - t2.clientY); + if (pinchStartDistance === 0) return; + + const centerScale = dist / pinchStartDistance; + let newScale = pinchStartScale * centerScale; + + // 最大放大至原始尺寸의 4 倍 + if (newScale > 4) newScale = 4; + + currentScale = newScale; + + const mediaView = this.contentEl.querySelector('.ge-media-view'); + if (mediaView && initialWidth > 0 && initialHeight > 0) { + if (newScale > 1) { + this.isZoomed = true; + this.contentEl.addClass('is-zoomed'); + + mediaView.setCssStyles({ + overflowX: 'scroll', + overflowY: 'scroll', + }); + + const newWidth = initialWidth * newScale; + const newHeight = initialHeight * newScale; + + img.setCssStyles({ + width: `${newWidth}px`, + height: `${newHeight}px`, + maxWidth: 'none', + maxHeight: 'none', + position: 'relative', + left: '0', + top: '0', + margin: 'auto', + transform: 'none', + cursor: 'zoom-out', + }); + + // 以雙指位置為中心縮放與拖曳 + const viewRect = mediaView.getBoundingClientRect(); + const currentCenterX = (t1.clientX + t2.clientX) / 2; + const currentCenterY = (t1.clientY + t2.clientY) / 2; + + const relativeCurrentCenterX = currentCenterX - viewRect.left; + const relativeCurrentCenterY = currentCenterY - viewRect.top; + + const imgLeftInView = newWidth < viewRect.width ? (viewRect.width - newWidth) / 2 : 0; + const imgTopInView = newHeight < viewRect.height ? (viewRect.height - newHeight) / 2 : 0; + + mediaView.scrollLeft = (pinchRatioX * newWidth) - relativeCurrentCenterX + imgLeftInView; + mediaView.scrollTop = (pinchRatioY * newHeight) - relativeCurrentCenterY + imgTopInView; + } else { + // 自動復原:縮小至小於或等於初始大小,自動重置為預設填滿模式 + this.resetImageStyles(img); + this.isZoomed = false; + this.contentEl.removeClass('is-zoomed'); + currentScale = 1; + } + } + } + }, { passive: false }); + + img.addEventListener('touchend', (e) => { + if (isPinching) { + if (e.touches.length < 2) { + isPinching = false; + if (currentScale <= 1) { + this.resetImageStyles(img); + this.isZoomed = false; + this.contentEl.removeClass('is-zoomed'); + currentScale = 1; + } + } + } + }); + + // 桌面端點擊事件(放大/縮小) + if (!Platform.isMobile) { img.addEventListener('click', (event) => { event.stopPropagation(); this.toggleImageZoom(img, event); @@ -468,13 +654,21 @@ export class MediaModal extends Modal { // 處理媒體右鍵選單 private onMediaContextMenu(event: MouseEvent, file: MediaFile) { - if ('isVirtual' in file && file.isVirtual) { - return; - } event.preventDefault(); const menu = new Menu(); if (file instanceof TFile) { - this.app.workspace.trigger('file-menu', menu, file, 'media-viewer'); + try { + this.app.workspace.trigger('file-menu', menu, file, 'media-viewer'); + } catch (err) { + console.error('Error triggering file-menu event:', err); + } + } else if ('isVirtual' in file && file.isVirtual) { + // 觸發自定義事件,讓其他外掛專案可以針對虛擬檔案(如 zip 中的圖片)自定義右鍵選單 + try { + this.app.workspace.trigger('gridexplorer:virtual-file-menu', menu, file, 'media-viewer'); + } catch (err) { + console.error('Error triggering gridexplorer:virtual-file-menu event:', err); + } } menu.showAtMouseEvent(event); } @@ -560,4 +754,8 @@ export class MediaModal extends Modal { this.isDragging = false; }, { passive: true }); } + + public getActiveFile(): MediaFile { + return this.mediaFiles[this.currentIndex]; + } } diff --git a/src/renderModePath.ts b/src/renderModePath.ts index 4d30405..0706c04 100644 --- a/src/renderModePath.ts +++ b/src/renderModePath.ts @@ -822,14 +822,21 @@ export function renderModePath(gridView: GridView) { case 'all-files': { if (gridView.plugin.settings.showMediaFiles && gridView.searchQuery === '') { // "顯示類型"選項 - const showTypeName = gridView.includeMedia ? t('random_note_include_media_files') : t('random_note_notes_only'); + let showTypeName = ''; + if (gridView.includeMedia === 'media-only') { + showTypeName = t('random_note_media_only'); + } else if (gridView.includeMedia === true) { + showTypeName = t('random_note_include_media_files'); + } else { + showTypeName = t('random_note_notes_only'); + } const showTypeSpan = modenameContainer.createEl('a', { text: showTypeName, cls: 'ge-sub-option' }); showTypeSpan.addEventListener('click', (evt) => { const menu = new Menu(); menu.addItem((item) => { item.setTitle(t('random_note_notes_only')) .setIcon('file-text') - .setChecked(!gridView.includeMedia) + .setChecked(gridView.includeMedia === false) .onClick(() => { gridView.includeMedia = false; void gridView.render(); @@ -838,12 +845,21 @@ export function renderModePath(gridView: GridView) { menu.addItem((item) => { item.setTitle(t('random_note_include_media_files')) .setIcon('file-image') - .setChecked(gridView.includeMedia) + .setChecked(gridView.includeMedia === true) .onClick(() => { gridView.includeMedia = true; void gridView.render(); }); }); + menu.addItem((item) => { + item.setTitle(t('random_note_media_only')) + .setIcon('image') + .setChecked(gridView.includeMedia === 'media-only') + .onClick(() => { + gridView.includeMedia = 'media-only'; + void gridView.render(); + }); + }); menu.showAtMouseEvent(evt); }); } diff --git a/src/translations/en.ts b/src/translations/en.ts index 5b8dda7..fcd3a29 100644 --- a/src/translations/en.ts +++ b/src/translations/en.ts @@ -176,6 +176,7 @@ export default { 'random_note_count': 'Random note count', 'random_note_notes_only': 'Notes Only', 'random_note_include_media_files': 'Include Media Files', + 'random_note_media_only': 'Media Only', 'show_tasks_mode': 'Show tasks mode', 'task_filter': 'Task filter', 'uncompleted': 'Uncompleted', diff --git a/src/translations/ja.ts b/src/translations/ja.ts index 96d479f..0fdd760 100644 --- a/src/translations/ja.ts +++ b/src/translations/ja.ts @@ -175,6 +175,7 @@ export default { 'random_note_count': 'ランダムノートモード表示筆数', 'random_note_notes_only': 'ノートのみ', 'random_note_include_media_files': 'メディアファイルを含む', + 'random_note_media_only': 'メディアファイルのみ', 'show_tasks_mode': 'タスクモードを表示', 'task_filter': 'タスクフィルタ', 'uncompleted': '未完了', diff --git a/src/translations/ko.ts b/src/translations/ko.ts index 0f22ee0..03f34a6 100644 --- a/src/translations/ko.ts +++ b/src/translations/ko.ts @@ -176,6 +176,7 @@ export default { 'random_note_count': '랜덤 노트 개수', 'random_note_notes_only': '노트만', 'random_note_include_media_files': '미디어 파일 포함', + 'random_note_media_only': '미디어 파일만', 'show_tasks_mode': '작업 모드 표시', 'task_filter': '작업 필터', 'uncompleted': '미완료', diff --git a/src/translations/ru.ts b/src/translations/ru.ts index c588b33..054e5fd 100644 --- a/src/translations/ru.ts +++ b/src/translations/ru.ts @@ -175,6 +175,7 @@ export default { 'random_note_count': 'Количество случайных заметок', 'random_note_notes_only': 'Только заметки', 'random_note_include_media_files': 'Включить медиафайлы', + 'random_note_media_only': 'Только медиафайлы', 'show_tasks_mode': 'Показывать режим задач', 'task_filter': 'Фильтр задач', 'uncompleted': 'Незавершенные', diff --git a/src/translations/uk.ts b/src/translations/uk.ts index 3716cfc..a507a9c 100644 --- a/src/translations/uk.ts +++ b/src/translations/uk.ts @@ -175,6 +175,7 @@ export default { 'random_note_count': 'Кількість випадкових нотаток', 'random_note_notes_only': 'Тільки нотатки', 'random_note_include_media_files': 'Включити медіафайли', + 'random_note_media_only': 'Тільки медіафайли', 'show_tasks_mode': 'Показувати режим завдань', 'task_filter': 'Фільтр завдань', 'uncompleted': 'Незавершені', diff --git a/src/translations/zh-TW.ts b/src/translations/zh-TW.ts index 4df16a9..526d180 100644 --- a/src/translations/zh-TW.ts +++ b/src/translations/zh-TW.ts @@ -176,6 +176,7 @@ export default { 'random_note_count': '隨機筆記模式顯示筆數', 'random_note_notes_only': '僅筆記', 'random_note_include_media_files': '包含媒體檔案', + 'random_note_media_only': '僅媒體檔案', 'show_tasks_mode': '顯示任務模式', 'task_filter': '任務分類', 'uncompleted': '未完成', diff --git a/src/translations/zh.ts b/src/translations/zh.ts index 0f15444..17d737b 100644 --- a/src/translations/zh.ts +++ b/src/translations/zh.ts @@ -175,6 +175,7 @@ export default { 'random_note_count': '随机笔记模式显示笔数', 'random_note_notes_only': '仅笔记', 'random_note_include_media_files': '包含媒体文件', + 'random_note_media_only': '仅媒体文件', 'show_tasks_mode': '显示任务模式', 'task_filter': '任务分类', 'uncompleted': '未完成', diff --git a/src/utils/fileUtils.ts b/src/utils/fileUtils.ts index 03cae4d..a20052a 100644 --- a/src/utils/fileUtils.ts +++ b/src/utils/fileUtils.ts @@ -321,7 +321,7 @@ export function ignoredFiles(files: TFile[], gridView: GridView): TFile[] { } // 獲取檔案 -export async function getFiles(gridView: GridView, includeMediaFiles: boolean): Promise { +export async function getFiles(gridView: GridView, includeMediaFiles: boolean | 'media-only'): Promise { const app = gridView.app; const settings = gridView.plugin.settings; const sourceMode = gridView.sourceMode; @@ -579,6 +579,9 @@ export async function getFiles(gridView: GridView, includeMediaFiles: boolean): } else if (sourceMode === 'all-files') { // 所有筆記模式 const allVaultFiles = app.vault.getFiles().filter(file => { + if (includeMediaFiles === 'media-only') { + return settings.showMediaFiles && isMediaFile(file); + } // 根據設定決定是否包含媒體檔案 if (isDocumentFile(file) || (settings.showMediaFiles && includeMediaFiles && isMediaFile(file))) { @@ -590,6 +593,9 @@ export async function getFiles(gridView: GridView, includeMediaFiles: boolean): } else if (sourceMode === 'recent-files') { // 最近檔案模式 const recentFiles = app.vault.getFiles().filter(file => { + if (includeMediaFiles === 'media-only') { + return settings.showMediaFiles && isMediaFile(file); + } // 根據設定決定是否包含媒體檔案 if (isDocumentFile(file) || (settings.showMediaFiles && includeMediaFiles && isMediaFile(file))) { @@ -602,6 +608,9 @@ export async function getFiles(gridView: GridView, includeMediaFiles: boolean): } else if (sourceMode === 'random-note') { // 隨機筆記模式,從所有筆記中隨機選取 const randomFiles = app.vault.getFiles().filter(file => { + if (includeMediaFiles === 'media-only') { + return settings.showMediaFiles && isMediaFile(file); + } // 根據設定決定是否包含媒體檔案 if (isDocumentFile(file) || (settings.showMediaFiles && includeMediaFiles && isMediaFile(file))) {