From e0c5da5fcffed995eeeccc57a86dcb2e27d3ec87 Mon Sep 17 00:00:00 2001 From: Devon22 Date: Mon, 8 Jun 2026 01:56:16 +0800 Subject: [PATCH] 3.3.0 --- README.md | 1 + README_ja.md | 1 + README_zhTW.md | 1 + manifest.json | 2 +- package-lock.json | 123 ++++- package.json | 6 +- src/ExplorerView.ts | 3 + src/GridPreviewManager.ts | 1073 +++++++++++++++++++++++++++++++++++++ src/GridView.ts | 560 +++---------------- src/ZipView.ts | 416 ++++++++++++++ src/handleKeyDown.ts | 4 +- src/main.ts | 8 + src/modal/mediaModal.ts | 120 ++++- src/renderModePath.ts | 7 +- src/settings.ts | 2 +- src/translations/en.ts | 7 + src/translations/ja.ts | 7 + src/translations/ko.ts | 7 + src/translations/ru.ts | 7 + src/translations/uk.ts | 7 + src/translations/zh-TW.ts | 7 + src/translations/zh.ts | 7 + src/utils/fileUtils.ts | 2 +- src/utils/mediaUtils.ts | 89 ++- styles.css | 341 +++++++++++- tsconfig.json | 1 + 26 files changed, 2282 insertions(+), 527 deletions(-) create mode 100644 src/GridPreviewManager.ts create mode 100644 src/ZipView.ts diff --git a/README.md b/README.md index 907ea16..e67ded6 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ Find "GridExplorer" in the Community plugins through the Obsidian app. - `pdf` - `canvas` - `base` +- `zip` ## Folder Notes diff --git a/README_ja.md b/README_ja.md index 262a076..6ad3852 100644 --- a/README_ja.md +++ b/README_ja.md @@ -66,6 +66,7 @@ Obsidian アプリのコミュニティプラグインから「GridExplorer」 - `pdf` - `canvas` - `base` +- `zip` ## フォルダノート diff --git a/README_zhTW.md b/README_zhTW.md index 603dff7..c96a188 100644 --- a/README_zhTW.md +++ b/README_zhTW.md @@ -66,6 +66,7 @@ - `pdf` - `canvas` - `base` +- `zip` ## 資料夾筆記 diff --git a/manifest.json b/manifest.json index dd4d26e..2b2789c 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "gridexplorer", "name": "GridExplorer", - "version": "3.2.7", + "version": "3.3.0", "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 d46ebf3..0f05127 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,18 @@ { "name": "gridexplorer", - "version": "3.2.7", + "version": "3.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gridexplorer", - "version": "3.2.7", + "version": "3.3.0", "license": "MIT", + "dependencies": { + "jszip": "^3.10.1" + }, "devDependencies": { + "@types/jszip": "^3.4.0", "@types/node": "^16.11.6", "@typescript-eslint/eslint-plugin": "^8.59.3", "@typescript-eslint/parser": "^8.59.3", @@ -686,6 +690,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jszip": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@types/jszip/-/jszip-3.4.0.tgz", + "integrity": "sha512-GFHqtQQP3R4NNuvZH3hNCYD0NbyBZ42bkN7kO3NDrU/SnvIZWMS8Bp38XCsRKBT5BXvgm0y1zqpZWp/ZkRzBzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "jszip": "*" + } + }, "node_modules/@types/node": { "version": "16.18.126", "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.126.tgz", @@ -1356,6 +1370,12 @@ "dev": true, "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/crelt": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", @@ -3141,6 +3161,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -3168,6 +3194,12 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -3720,6 +3752,18 @@ "node": ">=4.0" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -3744,6 +3788,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -4079,6 +4132,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -4139,6 +4198,12 @@ "node": ">= 0.8.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -4168,6 +4233,33 @@ "dev": true, "license": "MIT" }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -4434,6 +4526,12 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -4547,6 +4645,21 @@ "node": ">= 0.4" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/string.prototype.matchall": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", @@ -5015,6 +5128,12 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", diff --git a/package.json b/package.json index b09da57..134b391 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gridexplorer", - "version": "3.2.7", + "version": "3.3.0", "description": "Browse note files in a grid view.", "main": "main.js", "scripts": { @@ -12,6 +12,7 @@ "author": "", "license": "MIT", "devDependencies": { + "@types/jszip": "^3.4.0", "@types/node": "^16.11.6", "@typescript-eslint/eslint-plugin": "^8.59.3", "@typescript-eslint/parser": "^8.59.3", @@ -27,5 +28,8 @@ "optionalDependencies": { "@esbuild/linux-x64": "^0.17.3", "@esbuild/win32-x64": "^0.17.3" + }, + "dependencies": { + "jszip": "^3.10.1" } } diff --git a/src/ExplorerView.ts b/src/ExplorerView.ts index e2c9009..db64be9 100644 --- a/src/ExplorerView.ts +++ b/src/ExplorerView.ts @@ -1529,6 +1529,9 @@ export class ExplorerView extends ItemView { } else if (ext === 'base') { setIcon(itemIcon, 'layout-list'); itemIcon.addClass('ge-base'); + } else if (ext === 'zip') { + setIcon(itemIcon, 'folder-archive'); + itemIcon.addClass('ge-zip'); } else if (ext === 'md' || ext === 'txt') { setIcon(itemIcon, 'file-text'); } else { diff --git a/src/GridPreviewManager.ts b/src/GridPreviewManager.ts new file mode 100644 index 0000000..9735dc5 --- /dev/null +++ b/src/GridPreviewManager.ts @@ -0,0 +1,1073 @@ +import { TFile, Platform, setIcon, setTooltip, MarkdownRenderer } from 'obsidian'; +import JSZip from 'jszip'; +import { GridView } from './GridView'; +import { MediaModal, VirtualMediaFile } from './modal/mediaModal'; +import { t } from './translations'; + +interface NoteViewContainerWithKeydownHandler extends HTMLElement { + keydownHandler?: (e: KeyboardEvent) => void; +} + +interface AppWithInternalPlugins { + internalPlugins?: { + plugins: { + bookmarks?: { + enabled?: boolean; + }; + }; + getPluginById?: (id: string) => { + instance?: { + openGlobalSearch?: (query: string) => void; + }; + } | undefined; + }; +} + +export class GridPreviewManager { + private view: GridView; + + constructor(view: GridView) { + this.view = view; + } + + // 在網格視圖中直接顯示筆記 + async showNoteInGrid(file: TFile) { + // 關閉之前的筆記顯示 + if (this.view.isShowingNote) { + this.hideNoteInGrid(); + } + // 關閉之前的 ZIP 顯示 + if (this.view.isShowingZip) { + this.hideZipInGrid(); + } + + const gridContainer = this.view.containerEl.querySelector('.ge-grid-container'); + if (!gridContainer) return; + + // 創建筆記顯示容器 + this.view.noteViewContainer = this.view.containerEl.createDiv('ge-note-view-container'); + const noteViewContainer = this.view.noteViewContainer; + + // 頂部列 (左右區塊) + const topBar = noteViewContainer.createDiv('ge-note-top-bar'); + const leftBar = topBar.createDiv('ge-note-top-left'); + const rightBar = topBar.createDiv('ge-note-top-right'); + + // 筆記標題 + const noteTitle = leftBar.createDiv('ge-note-title'); + noteTitle.textContent = file.basename; + if (Platform.isDesktop) { + setTooltip(noteTitle, file.basename); + } + + // 編輯按鈕 + const editButton = rightBar.createEl('button', { cls: 'ge-note-edit-button' }); + setIcon(editButton, 'pencil'); + editButton.addEventListener('click', () => { + void this.view.getLeafByMode(file).openFile(file); + }); + + // Metadata 切換按鈕 + const infoButton = rightBar.createEl('button', { cls: 'ge-note-info-button' }); + setIcon(infoButton, 'info'); + + // 關閉按鈕 + const closeButton = rightBar.createEl('button', { cls: 'ge-note-close-button' }); + setIcon(closeButton, 'x'); + closeButton.addEventListener('click', () => { + this.hideNoteInGrid(); + }); + + // 捲動內容容器 + const scrollContainer = noteViewContainer.createDiv('ge-note-scroll-container'); + + // 假設在視圖側邊欄則把字型調小 + const isInSidebar = this.view.leaf.getRoot() === this.view.app.workspace.leftSplit || + this.view.leaf.getRoot() === this.view.app.workspace.rightSplit; + if (isInSidebar) { + scrollContainer.addClass('ge-note-sidebar-scroll-container'); + } + + // 創建筆記內容容器 + const noteContent = scrollContainer.createDiv('ge-note-content-container'); + if (isInSidebar) { + noteContent.addClass('ge-note-sidebar-content-container'); + } + + // 在移動端添加滾動監聽,根據滾動方向控制導航欄顯示/隱藏 + if (Platform.isPhone) { + let lastScrollTop = 0; + const handleScroll = () => { + const mobileNavbar = activeDocument.querySelector('.mobile-navbar') as HTMLElement; + if (!mobileNavbar) return; + + const currentScrollTop = scrollContainer.scrollTop; + + // 往上捲(滾動位置增加)時隱藏導航欄 + if (currentScrollTop > lastScrollTop && 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 (currentScrollTop < lastScrollTop) { + mobileNavbar.setCssProps({ + transform: 'translateY(0)', + transition: 'transform 0.3s ease-in', + }); + } + + lastScrollTop = currentScrollTop; + }; + + scrollContainer.addEventListener('scroll', handleScroll); + + // 監聽分頁切換事件,當離開當前視圖時恢復導航欄 + const handleActiveLeafChange = () => { + const activeView = this.view.app.workspace.getActiveViewOfType(GridView); + // 如果當前活動視圖不是這個 GridView 實例,或者不在顯示筆記狀態 + if (activeView !== this.view || !this.view.isShowingNote) { + const navbar = activeDocument.querySelector('.mobile-navbar') as HTMLElement; + if (navbar) { + navbar.setCssProps({ + transform: 'translateY(0)', + transition: 'transform 0.3s ease-in', + }); + } + } + }; + + // 註冊事件監聽器 + this.view.registerEvent( + this.view.app.workspace.on('active-leaf-change', handleActiveLeafChange) + ); + + // 儲存滾動事件清理函數 + this.view.eventCleanupFunctions.push(() => { + scrollContainer.removeEventListener('scroll', handleScroll); + }); + } + + // 取得 Metadata (Frontmatter) + const fileCache = this.view.app.metadataCache.getFileCache(file); + const frontmatter = fileCache?.frontmatter; + + if (frontmatter) { + // 檢查是否除了 position 以外還有其他屬性 + const keys = Object.keys(frontmatter).filter(k => k !== 'position'); + if (keys.length > 0) { + const metadataContainer = noteContent.createDiv('ge-note-metadata-container'); + + // 綁定切換事件 + infoButton.addEventListener('click', () => { + metadataContainer.classList.toggle('is-visible'); + scrollContainer.scrollTo(0, 0); + }); + + const metadataContent = metadataContainer.createDiv('ge-note-metadata-content'); + for (const key of keys) { + const item = metadataContent.createDiv('ge-note-metadata-item'); + item.createSpan({ cls: 'ge-note-metadata-key', text: `${key}: ` }); + const value: unknown = frontmatter[key] as unknown; + const valueSpan = item.createSpan({ cls: 'ge-note-metadata-value' }); + + const values = Array.isArray(value) ? value : [value]; + values.forEach((val, index) => { + const valStr = String(val); + // 處理內部連結 [[link]] 或 [[link|alias]] + const wikilinkRegex = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g; + // 處理 URL + const urlRegex = /(https?:\/\/[^\s]+)/g; + // 處理 Tag + const tagRegex = /#([^\s#]+)/g; + + if (wikilinkRegex.test(valStr)) { + wikilinkRegex.lastIndex = 0; + let lastIndex = 0; + let match; + while ((match = wikilinkRegex.exec(valStr)) !== null) { + // 插入匹配前的文字 + if (match.index > lastIndex) { + valueSpan.createSpan({ text: valStr.substring(lastIndex, match.index) }); + } + const linkPath = match[1]; + const linkAlias = match[2] || linkPath; + const linkEl = valueSpan.createEl('a', { + cls: 'internal-link', + text: linkAlias, + attr: { 'data-href': linkPath } + }); + linkEl.addEventListener('click', (e) => { + e.preventDefault(); + const linkedFile = this.view.app.metadataCache.getFirstLinkpathDest(linkPath, file.path); + if (linkedFile) { + void this.view.getLeafByMode(linkedFile).openFile(linkedFile); + } + }); + lastIndex = wikilinkRegex.lastIndex; + } + if (lastIndex < valStr.length) { + valueSpan.createSpan({ text: valStr.substring(lastIndex) }); + } + } else if (urlRegex.test(valStr)) { + urlRegex.lastIndex = 0; + let lastIndex = 0; + let match; + while ((match = urlRegex.exec(valStr)) !== null) { + if (match.index > lastIndex) { + valueSpan.createSpan({ text: valStr.substring(lastIndex, match.index) }); + } + const url = match[1]; + valueSpan.createEl('a', { + cls: 'external-link', + text: url, + attr: { 'href': url, 'target': '_blank', 'rel': 'noopener' } + }); + lastIndex = urlRegex.lastIndex; + } + if (lastIndex < valStr.length) { + valueSpan.createSpan({ text: valStr.substring(lastIndex) }); + } + } else if (key.toLowerCase() === 'tags' || key.toLowerCase() === 'tag' || tagRegex.test(valStr)) { + if ((key.toLowerCase() === 'tags' || key.toLowerCase() === 'tag') && !valStr.startsWith('#')) { + const tagEl = valueSpan.createEl('a', { + cls: 'tag', + text: '#' + valStr, + attr: { 'href': '#' + valStr } + }); + tagEl.addEventListener('click', (e) => { + e.preventDefault(); + (this.view.app as AppWithInternalPlugins).internalPlugins?.getPluginById?.('global-search')?.instance?.openGlobalSearch?.('tag:#' + valStr); + }); + } else { + tagRegex.lastIndex = 0; + let lastIndex = 0; + let match; + while ((match = tagRegex.exec(valStr)) !== null) { + if (match.index > lastIndex) { + valueSpan.createSpan({ text: valStr.substring(lastIndex, match.index) }); + } + const tagName = match[1]; + const tagEl = valueSpan.createEl('a', { + cls: 'tag', + text: '#' + tagName, + attr: { 'href': '#' + tagName } + }); + tagEl.addEventListener('click', (e) => { + e.preventDefault(); + (this.view.app as AppWithInternalPlugins).internalPlugins?.getPluginById?.('global-search')?.instance?.openGlobalSearch?.('tag:#' + tagName); + }); + lastIndex = tagRegex.lastIndex; + } + if (lastIndex < valStr.length) { + valueSpan.createSpan({ text: valStr.substring(lastIndex) }); + } + } + } else { + valueSpan.createSpan({ text: valStr }); + } + + if (index < values.length - 1) { + const isTag = key.toLowerCase() === 'tags' || key.toLowerCase() === 'tag'; + valueSpan.createSpan({ text: isTag ? ' ' : ', ' }); + } + }); + } + } + } + + // 創建筆記內容區域 + const noteContentArea = noteContent.createDiv('ge-note-content'); + + try { + // 讀取筆記內容 + const content = await this.view.app.vault.read(file); + + // 使用 Obsidian 的 MarkdownRenderer 渲染內容 + await MarkdownRenderer.render( + this.view.app, + content, + noteContentArea, + file.path, + this.view + ); + + // 加上自訂屬性 data-source-path + noteContentArea + .querySelectorAll('img') + .forEach((img) => (img.dataset.sourcePath = file.path)); + + // 處理內部連結點擊 + const handleLinkClick = (e: MouseEvent) => { + const target = e.target as HTMLElement; + const link = target.closest('a.internal-link'); + if (link) { + e.preventDefault(); + e.stopPropagation(); + + const href = link.getAttribute('href'); + if (href) { + const linkText = link.getAttribute('data-href') || href; + const linkedFile = this.view.app.metadataCache.getFirstLinkpathDest(linkText, file.path); + if (linkedFile) { + void this.view.getLeafByMode(linkedFile).openFile(linkedFile); + } + } + } + }; + + // 使用 registerDomEvent 註冊事件 + this.view.registerDomEvent(noteContentArea, 'click', handleLinkClick); + } catch (error) { + noteContentArea.textContent = '無法載入筆記內容'; + console.error('Error loading note content:', error); + } + + // 行動裝置下拉或上拉關閉筆記 + if (Platform.isMobile && noteViewContainer) { + let startY = 0; + let startX = 0; + let currentY = 0; + let isPulling = false; + let isPullingUp = false; + let isDragging = false; + let initialScrollTop = 0; + let isAtTop = false; + let isAtBottom = false; + + const handleTouchStart = (e: TouchEvent) => { + const target = e.target as HTMLElement; + if (target.closest('button') || target.closest('a') || target.closest('input') || target.closest('textarea')) { + return; + } + + initialScrollTop = scrollContainer.scrollTop; + isAtTop = initialScrollTop <= 0; + isAtBottom = initialScrollTop + scrollContainer.clientHeight >= scrollContainer.scrollHeight - 1; + + if (isAtTop || isAtBottom) { + startY = e.touches[0].clientY; + currentY = startY; + startX = e.touches[0].clientX; + isPulling = true; + isDragging = false; + isPullingUp = false; + } + }; + + const handleTouchMove = (e: TouchEvent) => { + if (!isPulling || !noteViewContainer) return; + + currentY = e.touches[0].clientY; + const currentX = e.touches[0].clientX; + const deltaY = currentY - startY; + const deltaX = currentX - startX; + + if (!isDragging) { + if (Math.abs(deltaX) > Math.abs(deltaY)) { + isPulling = false; + return; + } + + const pullDownStartThreshold = 24; + const pullUpStartThreshold = 36; + const canPullDown = isAtTop && deltaY > pullDownStartThreshold; + const canPullUp = isAtBottom && deltaY < -pullUpStartThreshold; + + if (canPullDown || canPullUp) { + isDragging = true; + isPullingUp = canPullUp && deltaY < 0; + if (noteViewContainer) { + noteViewContainer.setCssProps({ transition: 'none' }); + } + } else if ((isAtTop && !isAtBottom && deltaY < 0) || (isAtBottom && !isAtTop && deltaY > 0)) { + isPulling = false; + return; + } + } + + if (isDragging) { + if (e.cancelable) { + e.preventDefault(); + } + const resistance = 0.5; + const translateY = deltaY * resistance; + noteViewContainer.setCssProps({ transform: `translateY(${translateY}px)` }); + } + }; + + const handleTouchEnd = () => { + if (!isPulling || !noteViewContainer) return; + isPulling = false; + + if (!isDragging) return; + isDragging = false; + + const deltaY = currentY - startY; + + const closeThreshold = isPullingUp ? 170 : 110; + if ((!isPullingUp && deltaY > closeThreshold) || (isPullingUp && deltaY < -closeThreshold)) { + const targetY = isPullingUp ? '-100vh' : '100vh'; + noteViewContainer.setCssProps({ + transition: 'transform 0.2s ease-out', + transform: `translateY(${targetY})`, + }); + window.setTimeout(() => { + this.hideNoteInGrid(); + if (noteViewContainer) { + noteViewContainer.setCssProps({ + transform: '', + transition: '', + }); + } + }, 200); + } else { + noteViewContainer.setCssProps({ + transition: 'transform 0.3s ease-out', + transform: 'translateY(0)', + }); + window.setTimeout(() => { + if (noteViewContainer) { + noteViewContainer.setCssProps({ + transform: '', + transition: '', + }); + } + }, 300); + } + }; + + noteViewContainer.addEventListener('touchstart', handleTouchStart, { passive: true }); + noteViewContainer.addEventListener('touchmove', handleTouchMove, { passive: false }); + noteViewContainer.addEventListener('touchend', handleTouchEnd); + } + + // 設定狀態 + this.view.isShowingNote = true; + + // 註冊鍵盤事件監聽器 + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + this.hideNoteInGrid(); + e.preventDefault(); + } + }; + + activeDocument.addEventListener('keydown', handleKeyDown); + + // 儲存事件監聽器以便後續移除 + (noteViewContainer as NoteViewContainerWithKeydownHandler).keydownHandler = handleKeyDown; + } + + // 隱藏筆記顯示 + hideNoteInGrid() { + if (!this.view.isShowingNote) return; + + // 顯示移動端導航欄 (僅在行動裝置上) + if (Platform.isPhone) { + const mobileNavbar = activeDocument.querySelector('.mobile-navbar') as HTMLElement; + if (mobileNavbar) { + mobileNavbar.setCssProps({ + transform: 'translateY(0)', + transition: 'transform 0.3s ease-in', + }); + } + } + + if (this.view.noteViewContainer) { + // 移除鍵盤事件監聽器 + const keydownHandler = (this.view.noteViewContainer as NoteViewContainerWithKeydownHandler).keydownHandler; + if (keydownHandler) { + activeDocument.removeEventListener('keydown', keydownHandler); + } + + this.view.noteViewContainer.remove(); + this.view.noteViewContainer = null; + } + + this.view.isShowingNote = false; + } + + // 在網格視圖中直接顯示 ZIP 圖片網格 + async showZipInGrid(file: TFile) { + // 關閉之前的 ZIP 顯示 + if (this.view.isShowingZip) { + this.hideZipInGrid(); + } + // 關閉之前的筆記顯示 + if (this.view.isShowingNote) { + this.hideNoteInGrid(); + } + + const gridContainer = this.view.containerEl.querySelector('.ge-grid-container'); + if (!gridContainer) return; + + // 創建 ZIP 顯示容器 + this.view.zipViewContainer = this.view.containerEl.createDiv('ge-zip-view-container'); + const zipViewContainer = this.view.zipViewContainer; + + // 頂部列 (左右區塊) + const topBar = zipViewContainer.createDiv('ge-zip-top-bar'); + const leftBar = topBar.createDiv('ge-zip-top-left'); + const rightBar = topBar.createDiv('ge-zip-top-right'); + + // ZIP 標題 + const zipTitle = leftBar.createDiv('ge-zip-title'); + zipTitle.textContent = file.basename; + if (Platform.isDesktop) { + setTooltip(zipTitle, file.basename); + } + + // 開啟按鈕 (在分頁中開啟 ZIP 檔案) + const openButton = rightBar.createEl('button', { cls: 'ge-zip-open-button' }); + setIcon(openButton, 'folder-archive'); + openButton.setAttribute('aria-label', t('zip_open_file')); + if (Platform.isDesktop) { + setTooltip(openButton, t('zip_open_file')); + } + openButton.addEventListener('click', () => { + void this.view.getLeafByMode(file).openFile(file); + }); + + // 關閉按鈕 + const closeButton = rightBar.createEl('button', { cls: 'ge-zip-close-button' }); + setIcon(closeButton, 'x'); + closeButton.setAttribute('aria-label', t('zip_close_view')); + if (Platform.isDesktop) { + setTooltip(closeButton, t('zip_close_view')); + } + closeButton.addEventListener('click', () => { + this.hideZipInGrid(); + }); + + // 捲動內容容器 + const scrollContainer = zipViewContainer.createDiv('ge-zip-scroll-container'); + + // 假設在視圖側邊欄則把字型調小 + const isInSidebar = this.view.leaf.getRoot() === this.view.app.workspace.leftSplit || + this.view.leaf.getRoot() === this.view.app.workspace.rightSplit; + if (isInSidebar) { + scrollContainer.addClass('ge-zip-sidebar-scroll-container'); + } + + // 創建圖片網格容器 + const zipContent = scrollContainer.createDiv('ge-zip-content-container'); + if (isInSidebar) { + zipContent.addClass('ge-zip-sidebar-content-container'); + } + const zipGridEl = zipContent.createDiv('zip-viewer-grid'); + + // 在移動端添加滾動監聽,控制導航欄 + if (Platform.isPhone) { + let lastScrollTop = 0; + const handleScroll = () => { + const mobileNavbar = activeDocument.querySelector('.mobile-navbar') as HTMLElement; + if (!mobileNavbar) return; + + const currentScrollTop = scrollContainer.scrollTop; + + if (currentScrollTop > lastScrollTop && 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 (currentScrollTop < lastScrollTop) { + mobileNavbar.setCssProps({ + transform: 'translateY(0)', + transition: 'transform 0.3s ease-in', + }); + } + lastScrollTop = currentScrollTop; + }; + + scrollContainer.addEventListener('scroll', handleScroll); + + const handleActiveLeafChange = () => { + const activeView = this.view.app.workspace.getActiveViewOfType(GridView); + if (activeView !== this.view || !this.view.isShowingZip) { + const navbar = activeDocument.querySelector('.mobile-navbar') as HTMLElement; + if (navbar) { + navbar.setCssProps({ + transform: 'translateY(0)', + transition: 'transform 0.3s ease-in', + }); + } + } + }; + + this.view.registerEvent( + this.view.app.workspace.on('active-leaf-change', handleActiveLeafChange) + ); + + this.view.eventCleanupFunctions.push(() => { + scrollContainer.removeEventListener('scroll', handleScroll); + }); + } + + // 載入與解析 ZIP 檔案 + const loadingEl = zipContent.createDiv({ cls: 'ge-zip-loading', text: t('zip_loading') }); + + try { + const arrayBuffer = await this.view.app.vault.readBinary(file); + const zip = await JSZip.loadAsync(arrayBuffer); + this.view.activeZip = zip; + + const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico']; + this.view.zipImageFiles = Object.keys(zip.files) + .filter(filename => { + const lower = filename.toLowerCase(); + return !zip.files[filename].dir && + imageExtensions.some(ext => lower.endsWith(ext)) && + !lower.includes('__macosx'); + }) + .sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' })); + + loadingEl.remove(); + + if (this.view.zipImageFiles.length === 0) { + zipContent.createDiv({ cls: 'ge-zip-empty', text: t('zip_no_images') }); + this.view.isShowingZip = true; + return; + } + + // 更新標題以顯示圖片張數 + zipTitle.textContent = file.basename; + + // 建立網格項目 + this.view.zipObserver = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + const img = entry.target as HTMLImageElement; + const item = img.closest('.zip-viewer-grid-item'); + if (!item) return; + + const indexStr = item.getAttribute('data-index'); + if (indexStr === null) return; + const index = parseInt(indexStr, 10); + + if (this.view.zipObserver) { + this.view.zipObserver.unobserve(img); + } + + if (this.view.zipThumbnailUrls.has(index)) { + img.src = this.view.zipThumbnailUrls.get(index)!; + img.addClass('lazy-loaded'); + return; + } + + if (!this.view.activeZip) return; + const filename = this.view.zipImageFiles[index]; + void (async () => { + try { + const fileObject = this.view.activeZip!.files[filename]; + const blob = await fileObject.async("blob"); + const url = URL.createObjectURL(blob); + this.view.zipThumbnailUrls.set(index, url); + + img.src = url; + img.addClass('lazy-loaded'); + } catch (err) { + console.error(`Error loading ZIP preview for index ${index}:`, err); + } + })(); + } + }); + }, { + root: scrollContainer, + rootMargin: '100px', + threshold: 0.01 + }); + + this.view.zipImageFiles.forEach((filename, index) => { + const displayName = filename.split('/').pop() || filename; + + const item = zipGridEl.createDiv({ cls: 'zip-viewer-grid-item' }); + item.setAttribute('data-index', index.toString()); + item.setAttribute('data-file-path', filename); + + const imgWrapper = item.createDiv({ cls: 'zip-viewer-grid-item-img-wrapper' }); + const img = imgWrapper.createEl('img'); + + const label = item.createDiv({ cls: 'zip-viewer-grid-item-label' }); + label.setText(displayName); + + item.addEventListener('click', (e) => { + e.stopPropagation(); + this.openZipMediaModal(index, zipGridEl); + }); + + if (this.view.zipObserver) { + this.view.zipObserver.observe(img); + } + }); + + this.selectZipItem(0, zipGridEl); + + } catch (error) { + loadingEl.textContent = t('zip_load_error'); + console.error('Error loading ZIP content in grid:', error); + } + + // 行動裝置下拉關閉 + if (Platform.isMobile && zipViewContainer) { + let startY = 0; + let startX = 0; + let currentY = 0; + let isPulling = false; + let isPullingUp = false; + let isDragging = false; + let initialScrollTop = 0; + let isAtTop = false; + let isAtBottom = false; + + const handleTouchStart = (e: TouchEvent) => { + const target = e.target as HTMLElement; + if (target.closest('button') || target.closest('a') || target.closest('input') || target.closest('textarea')) { + return; + } + + initialScrollTop = scrollContainer.scrollTop; + isAtTop = initialScrollTop <= 0; + isAtBottom = initialScrollTop + scrollContainer.clientHeight >= scrollContainer.scrollHeight - 1; + + if (isAtTop || isAtBottom) { + startY = e.touches[0].clientY; + currentY = startY; + startX = e.touches[0].clientX; + isPulling = true; + isDragging = false; + isPullingUp = false; + } + }; + + const handleTouchMove = (e: TouchEvent) => { + if (!isPulling || !zipViewContainer) return; + + currentY = e.touches[0].clientY; + const currentX = e.touches[0].clientX; + const deltaY = currentY - startY; + const deltaX = currentX - startX; + + if (!isDragging) { + if (Math.abs(deltaX) > Math.abs(deltaY)) { + isPulling = false; + return; + } + + const pullDownStartThreshold = 24; + const pullUpStartThreshold = 36; + const canPullDown = isAtTop && deltaY > pullDownStartThreshold; + const canPullUp = isAtBottom && deltaY < -pullUpStartThreshold; + + if (canPullDown || canPullUp) { + isDragging = true; + isPullingUp = canPullUp && deltaY < 0; + if (zipViewContainer) { + zipViewContainer.setCssProps({ transition: 'none' }); + } + } else if ((isAtTop && !isAtBottom && deltaY < 0) || (isAtBottom && !isAtTop && deltaY > 0)) { + isPulling = false; + return; + } + } + + if (isDragging) { + if (e.cancelable) { + e.preventDefault(); + } + const resistance = 0.5; + const translateY = deltaY * resistance; + zipViewContainer.setCssProps({ transform: `translateY(${translateY}px)` }); + } + }; + + const handleTouchEnd = () => { + if (!isPulling || !zipViewContainer) return; + isPulling = false; + + if (!isDragging) return; + isDragging = false; + + const deltaY = currentY - startY; + + const closeThreshold = isPullingUp ? 170 : 110; + if ((!isPullingUp && deltaY > closeThreshold) || (isPullingUp && deltaY < -closeThreshold)) { + const targetY = isPullingUp ? '-100vh' : '100vh'; + zipViewContainer.setCssProps({ + transition: 'transform 0.2s ease-out', + transform: `translateY(${targetY})`, + }); + window.setTimeout(() => { + this.hideZipInGrid(); + if (zipViewContainer) { + zipViewContainer.setCssProps({ + transform: '', + transition: '', + }); + } + }, 200); + } else { + zipViewContainer.setCssProps({ + transition: 'transform 0.3s ease-out', + transform: 'translateY(0)', + }); + window.setTimeout(() => { + if (zipViewContainer) { + zipViewContainer.setCssProps({ + transform: '', + transition: '', + }); + } + }, 300); + } + }; + + zipViewContainer.addEventListener('touchstart', handleTouchStart, { passive: true }); + zipViewContainer.addEventListener('touchmove', handleTouchMove, { passive: false }); + zipViewContainer.addEventListener('touchend', handleTouchEnd); + } + + // 設定狀態 + this.view.isShowingZip = true; + + // 註冊鍵盤事件監聽器 + const handleKeyDown = (e: KeyboardEvent) => { + const target = e.target as HTMLElement | null; + if (target && ( + target.isContentEditable || + target.closest('input, textarea, select, [contenteditable="true"]') + )) { + return; + } + + if (this.view.zipImageFiles.length === 0) return; + + // 如果有 Modal 視窗,則不處理(讓 Modal 自己處理) + if (activeDocument.querySelector('.modal-container')) return; + + let newIndex = this.view.zipCurrentIndex; + + // 如果還沒有選中項目(zipCurrentIndex === -1)且按下了方向鍵,選中第一個項目 + if (this.view.zipCurrentIndex === -1 && + ['ArrowRight', 'ArrowLeft', 'ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) { + this.selectZipItem(0, zipGridEl); + e.preventDefault(); + return; + } + + const gridItems = Array.from(zipGridEl.querySelectorAll('.zip-viewer-grid-item')); + + switch (e.key) { + case 'ArrowRight': + newIndex = Math.min(this.view.zipImageFiles.length - 1, this.view.zipCurrentIndex + 1); + e.preventDefault(); + break; + case 'ArrowLeft': + newIndex = Math.max(0, this.view.zipCurrentIndex - 1); + e.preventDefault(); + break; + case 'ArrowDown': + if (this.view.zipCurrentIndex >= 0 && gridItems.length > 0) { + const currentItem = gridItems[this.view.zipCurrentIndex]; + const currentRect = currentItem.getBoundingClientRect(); + const currentCenterX = currentRect.left + currentRect.width / 2; + const currentBottom = currentRect.bottom; + + let closestItem = -1; + let minDistance = Number.MAX_VALUE; + let minVerticalDistance = Number.MAX_VALUE; + + for (let i = 0; i < gridItems.length; i++) { + if (i === this.view.zipCurrentIndex) continue; + + const itemRect = gridItems[i].getBoundingClientRect(); + const itemCenterX = itemRect.left + itemRect.width / 2; + const itemTop = itemRect.top; + + if (itemTop <= currentBottom) continue; + + const horizontalDistance = Math.abs(itemCenterX - currentCenterX); + const verticalDistance = itemTop - currentBottom; + + if (verticalDistance < minVerticalDistance || + (verticalDistance === minVerticalDistance && horizontalDistance < minDistance)) { + minVerticalDistance = verticalDistance; + minDistance = horizontalDistance; + closestItem = i; + } + } + + if (closestItem !== -1) { + newIndex = closestItem; + } else { + newIndex = gridItems.length - 1; + } + } else { + newIndex = 0; + } + e.preventDefault(); + break; + case 'ArrowUp': + if (this.view.zipCurrentIndex >= 0 && gridItems.length > 0) { + const currentItem = gridItems[this.view.zipCurrentIndex]; + const currentRect = currentItem.getBoundingClientRect(); + const currentCenterX = currentRect.left + currentRect.width / 2; + const currentTop = currentRect.top; + + let closestItem = -1; + let minDistance = Number.MAX_VALUE; + let minVerticalDistance = Number.MAX_VALUE; + + for (let i = 0; i < gridItems.length; i++) { + if (i === this.view.zipCurrentIndex) continue; + + const itemRect = gridItems[i].getBoundingClientRect(); + const itemCenterX = itemRect.left + itemRect.width / 2; + const itemBottom = itemRect.bottom; + + if (itemBottom >= currentTop) continue; + + const horizontalDistance = Math.abs(itemCenterX - currentCenterX); + const verticalDistance = currentTop - itemBottom; + + if (verticalDistance < minVerticalDistance || + (verticalDistance === minVerticalDistance && horizontalDistance < minDistance)) { + minVerticalDistance = verticalDistance; + minDistance = horizontalDistance; + closestItem = i; + } + } + + if (closestItem !== -1) { + newIndex = closestItem; + } else { + newIndex = 0; + } + } else { + newIndex = 0; + } + e.preventDefault(); + break; + case 'Home': + newIndex = 0; + e.preventDefault(); + break; + case 'End': + newIndex = this.view.zipImageFiles.length - 1; + e.preventDefault(); + break; + case 'Enter': + if (this.view.zipCurrentIndex >= 0 && this.view.zipCurrentIndex < this.view.zipImageFiles.length) { + this.openZipMediaModal(this.view.zipCurrentIndex, zipGridEl); + } + e.preventDefault(); + break; + } + + if (newIndex !== this.view.zipCurrentIndex) { + this.selectZipItem(newIndex, zipGridEl); + } + }; + + activeDocument.addEventListener('keydown', handleKeyDown); + + // 儲存事件監聽器以便後續移除 + (zipViewContainer as NoteViewContainerWithKeydownHandler).keydownHandler = handleKeyDown; + } + + // 開啟 ZIP 內部圖片的 MediaModal + openZipMediaModal(index: number, gridEl: HTMLElement) { + if (!this.view.activeZip) return; + + // 建立虛擬 Media 檔案列表 + const virtualMediaFiles: VirtualMediaFile[] = this.view.zipImageFiles.map(filename => { + return { + name: filename.split('/').pop() || filename, + path: filename, + isVirtual: true, + getBlobUrl: async () => { + if (!this.view.activeZip) throw new Error("Zip is not loaded"); + const fileObject = this.view.activeZip.files[filename]; + const blob = await fileObject.async("blob"); + return URL.createObjectURL(blob); + } + }; + }); + + const activeVirtualFile = virtualMediaFiles[index]; + + const focusTarget = { + gridItems: Array.from(gridEl.querySelectorAll('.zip-viewer-grid-item')), + hasKeyboardFocus: true, + selectItem: (idx: number) => { + this.selectZipItem(idx, gridEl); + if (this.view.zipViewContainer) { + this.view.zipViewContainer.focus(); + } + } + }; + + const modal = new MediaModal(this.view.app, activeVirtualFile, virtualMediaFiles, focusTarget); + modal.open(); + } + + // 隱藏 ZIP 顯示 + hideZipInGrid() { + if (!this.view.isShowingZip) return; + + // 顯示移動端導航欄 (僅在行動裝置上) + if (Platform.isPhone) { + const mobileNavbar = activeDocument.querySelector('.mobile-navbar') as HTMLElement; + if (mobileNavbar) { + mobileNavbar.setCssProps({ + transform: 'translateY(0)', + transition: 'transform 0.3s ease-in', + }); + } + } + + if (this.view.zipObserver) { + this.view.zipObserver.disconnect(); + this.view.zipObserver = null; + } + + this.view.zipThumbnailUrls.forEach(url => { + URL.revokeObjectURL(url); + }); + this.view.zipThumbnailUrls.clear(); + this.view.zipImageFiles = []; + this.view.activeZip = null; + this.view.zipCurrentIndex = -1; + + if (this.view.zipViewContainer) { + const keydownHandler = (this.view.zipViewContainer as NoteViewContainerWithKeydownHandler).keydownHandler; + if (keydownHandler) { + activeDocument.removeEventListener('keydown', keydownHandler); + } + + this.view.zipViewContainer.remove(); + this.view.zipViewContainer = null; + } + + this.view.isShowingZip = false; + } + + selectZipItem(idx: number, gridEl: HTMLElement) { + if (idx < 0 || idx >= this.view.zipImageFiles.length) return; + this.view.zipCurrentIndex = idx; + const items = gridEl.querySelectorAll('.zip-viewer-grid-item'); + items.forEach((item, itemIdx) => { + if (itemIdx === idx) { + item.addClass('current'); + item.scrollIntoView({ block: 'center', behavior: 'smooth' }); + } else { + item.removeClass('current'); + } + }); + } +} diff --git a/src/GridView.ts b/src/GridView.ts index 8eb56ef..4fa21b6 100644 --- a/src/GridView.ts +++ b/src/GridView.ts @@ -1,4 +1,5 @@ -import { WorkspaceLeaf, ItemView, TFolder, TFile, Menu, Notice, Platform, setIcon, getFrontMatterInfo, FrontMatterCache, normalizePath, setTooltip, MarkdownRenderer } from 'obsidian'; +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'; @@ -7,7 +8,7 @@ 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 } from './utils/mediaUtils'; +import { findFirstImageInNote, getFirstImageFromZip } from './utils/mediaUtils'; import { isHexColor, hexToRgba } from './utils/colorUtils'; import { showFolderSelectionModal } from './modal/folderSelectionModal'; import { MediaModal } from './modal/mediaModal'; @@ -15,6 +16,7 @@ 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'; // 定義分隔器狀態 @@ -93,10 +95,6 @@ interface WorkspaceSplitWithChildren { children?: unknown[]; } -interface NoteViewContainerWithKeydownHandler extends HTMLElement { - keydownHandler?: (e: KeyboardEvent) => void; -} - interface GridViewStateData { sourceMode?: string; sourcePath?: string; @@ -190,12 +188,21 @@ export class GridView extends ItemView { 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; @@ -1287,6 +1294,16 @@ export class GridView extends ItemView { // 如果沒有圖片,移除圖片區域 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(); @@ -1577,6 +1594,9 @@ export class GridView extends ItemView { } else if (extension === 'base') { const iconContainer = titleContainer.createDiv('ge-icon-container ge-base'); setIcon(iconContainer, 'layout-list'); + } else if (extension === 'zip') { + const iconContainer = titleContainer.createDiv('ge-icon-container ge-zip'); + setIcon(iconContainer, 'folder-archive'); } else if (extension === 'md' || extension === 'txt') { const iconContainer = titleContainer.createDiv('ge-icon-container'); setIcon(iconContainer, 'file-text'); @@ -1602,27 +1622,28 @@ export class GridView extends ItemView { // 加入滑鼠移入顯示的右上角圓形按鈕(僅針對可在網格中顯示筆記的檔案) // 位置與顯示由 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)) { - // this.showNoteInGrid(file); - // } - // }); - // // 阻止滑鼠事件影響拖曳或選取 - // quickBtn.addEventListener('mousedown', (e) => { - // e.stopPropagation(); - // }); - // } + /* + 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 鍵直接顯示筆記 - if (Platform.isDesktop && file.extension === 'md' && !this.plugin.settings.showNoteInGrid) { + // 滑鼠懸停在項目上時,按 Ctrl 鍵直接顯示筆記或 ZIP 圖片網格 + if (Platform.isDesktop && (file.extension === 'md' || file.extension === 'zip') && !this.plugin.settings.showNoteInGrid) { let triggeredInHover = false; let isHovering = false; let isMouseDown = false; // 追蹤滑鼠按下狀態 @@ -1632,7 +1653,11 @@ export class GridView extends ItemView { if (triggeredInHover || isMouseDown) return; // 如果滑鼠按下則不觸發 triggeredInHover = true; if (!this.openShortcutFile(file)) { - void this.showNoteInGrid(file); + if (file.extension === 'md') { + void this.showNoteInGrid(file); + } else if (file.extension === 'zip') { + void this.showZipInGrid(file); + } } }; @@ -1763,7 +1788,9 @@ export class GridView extends ItemView { if (!this.openShortcutFile(file)) { if(file.extension === 'md') { void this.showNoteInGrid(file); // 在網格視圖中直接顯示筆記 - } else { + } else if (file.extension === 'zip') { + void this.showZipInGrid(file); // 在網格視圖中直接顯示 ZIP + } else { void this.getLeafByMode(file).openFile(file); } } @@ -2328,474 +2355,31 @@ export class GridView extends ItemView { // 在網格視圖中直接顯示筆記 async showNoteInGrid(file: TFile) { - // 關閉之前的筆記顯示 - if (this.isShowingNote) { - this.hideNoteInGrid(); - } - - const gridContainer = this.containerEl.querySelector('.ge-grid-container'); - if (!gridContainer) return; - - // 創建筆記顯示容器 - this.noteViewContainer = this.containerEl.createDiv('ge-note-view-container'); - - // 頂部列 (左右區塊) - const topBar = this.noteViewContainer.createDiv('ge-note-top-bar'); - const leftBar = topBar.createDiv('ge-note-top-left'); - const rightBar = topBar.createDiv('ge-note-top-right'); - - // 筆記標題 - const noteTitle = leftBar.createDiv('ge-note-title'); - noteTitle.textContent = file.basename; - if (Platform.isDesktop) { - setTooltip(noteTitle, file.basename); - } - - // 編輯按鈕 - const editButton = rightBar.createEl('button', { cls: 'ge-note-edit-button' }); - setIcon(editButton, 'pencil'); - editButton.addEventListener('click', () => { - void this.getLeafByMode(file).openFile(file); - }); - - // Metadata 切換按鈕 - const infoButton = rightBar.createEl('button', { cls: 'ge-note-info-button' }); - setIcon(infoButton, 'info') - - // 關閉按鈕 - const closeButton = rightBar.createEl('button', { cls: 'ge-note-close-button' }); - setIcon(closeButton, 'x'); - closeButton.addEventListener('click', () => { - this.hideNoteInGrid(); - }); - - // 捲動內容容器 - const scrollContainer = this.noteViewContainer.createDiv('ge-note-scroll-container'); - - // 假設在視圖側邊欄則把字型調小 - const isInSidebar = this.leaf.getRoot() === this.app.workspace.leftSplit || - this.leaf.getRoot() === this.app.workspace.rightSplit; - if (isInSidebar) { - scrollContainer.addClass('ge-note-sidebar-scroll-container'); - } - - // 創建筆記內容容器 - const noteContent = scrollContainer.createDiv('ge-note-content-container'); - if (isInSidebar) { - noteContent.addClass('ge-note-sidebar-content-container'); - } - - // 在移動端添加滾動監聽,根據滾動方向控制導航欄顯示/隱藏 - if (Platform.isPhone) { - let lastScrollTop = 0; - const handleScroll = () => { - const mobileNavbar = activeDocument.querySelector('.mobile-navbar') as HTMLElement; - if (!mobileNavbar) return; - - const currentScrollTop = scrollContainer.scrollTop; - - // 往上捲(滾動位置增加)時隱藏導航欄 - if (currentScrollTop > lastScrollTop && 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 (currentScrollTop < lastScrollTop) { - mobileNavbar.setCssProps({ - transform: 'translateY(0)', - transition: 'transform 0.3s ease-in', - }); - } - - lastScrollTop = currentScrollTop; - }; - - scrollContainer.addEventListener('scroll', handleScroll); - - // 監聽分頁切換事件,當離開當前視圖時恢復導航欄 - const handleActiveLeafChange = () => { - const activeView = this.app.workspace.getActiveViewOfType(GridView); - // 如果當前活動視圖不是這個 GridView 實例,或者不在顯示筆記狀態 - if (activeView !== this || !this.isShowingNote) { - const navbar = activeDocument.querySelector('.mobile-navbar') as HTMLElement; - if (navbar) { - navbar.setCssProps({ - transform: 'translateY(0)', - transition: 'transform 0.3s ease-in', - }); - } - } - }; - - // 註冊事件監聽器 - this.registerEvent( - this.app.workspace.on('active-leaf-change', handleActiveLeafChange) - ); - - // 儲存滾動事件清理函數 - this.eventCleanupFunctions.push(() => { - scrollContainer.removeEventListener('scroll', handleScroll); - }); - } - - // 取得 Metadata (Frontmatter) - const fileCache = this.app.metadataCache.getFileCache(file); - const frontmatter = fileCache?.frontmatter; - - if (frontmatter) { - // 檢查是否除了 position 以外還有其他屬性 - const keys = Object.keys(frontmatter).filter(k => k !== 'position'); - if (keys.length > 0) { - const metadataContainer = noteContent.createDiv('ge-note-metadata-container'); - - // 綁定切換事件 - infoButton.addEventListener('click', () => { - metadataContainer.classList.toggle('is-visible'); - scrollContainer.scrollTo(0, 0); - }); - - const metadataContent = metadataContainer.createDiv('ge-note-metadata-content'); - for (const key of keys) { - const item = metadataContent.createDiv('ge-note-metadata-item'); - item.createSpan({ cls: 'ge-note-metadata-key', text: `${key}: ` }); - const value: unknown = frontmatter[key] as unknown; - const valueSpan = item.createSpan({ cls: 'ge-note-metadata-value' }); - - const values = Array.isArray(value) ? value : [value]; - values.forEach((val, index) => { - const valStr = String(val); - // 處理內部連結 [[link]] 或 [[link|alias]] - const wikilinkRegex = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g; - // 處理 URL - const urlRegex = /(https?:\/\/[^\s]+)/g; - // 處理 Tag - const tagRegex = /#([^\s#]+)/g; - - if (wikilinkRegex.test(valStr)) { - wikilinkRegex.lastIndex = 0; - let lastIndex = 0; - let match; - while ((match = wikilinkRegex.exec(valStr)) !== null) { - // 插入匹配前的文字 - if (match.index > lastIndex) { - valueSpan.createSpan({ text: valStr.substring(lastIndex, match.index) }); - } - const linkPath = match[1]; - const linkAlias = match[2] || linkPath; - const linkEl = valueSpan.createEl('a', { - cls: 'internal-link', - text: linkAlias, - attr: { 'data-href': linkPath } - }); - linkEl.addEventListener('click', (e) => { - e.preventDefault(); - const linkedFile = this.app.metadataCache.getFirstLinkpathDest(linkPath, file.path); - if (linkedFile) { - void this.getLeafByMode(linkedFile).openFile(linkedFile); - } - }); - lastIndex = wikilinkRegex.lastIndex; - } - if (lastIndex < valStr.length) { - valueSpan.createSpan({ text: valStr.substring(lastIndex) }); - } - } else if (urlRegex.test(valStr)) { - urlRegex.lastIndex = 0; - let lastIndex = 0; - let match; - while ((match = urlRegex.exec(valStr)) !== null) { - if (match.index > lastIndex) { - valueSpan.createSpan({ text: valStr.substring(lastIndex, match.index) }); - } - const url = match[1]; - valueSpan.createEl('a', { - cls: 'external-link', - text: url, - attr: { 'href': url, 'target': '_blank', 'rel': 'noopener' } - }); - lastIndex = urlRegex.lastIndex; - } - if (lastIndex < valStr.length) { - valueSpan.createSpan({ text: valStr.substring(lastIndex) }); - } - } else if (key.toLowerCase() === 'tags' || key.toLowerCase() === 'tag' || tagRegex.test(valStr)) { - if ((key.toLowerCase() === 'tags' || key.toLowerCase() === 'tag') && !valStr.startsWith('#')) { - const tagEl = valueSpan.createEl('a', { - cls: 'tag', - text: '#' + valStr, - attr: { 'href': '#' + valStr } - }); - tagEl.addEventListener('click', (e) => { - e.preventDefault(); - (this.app as AppWithInternalPlugins).internalPlugins?.getPluginById?.('global-search')?.instance?.openGlobalSearch?.('tag:#' + valStr); - }); - } else { - tagRegex.lastIndex = 0; - let lastIndex = 0; - let match; - while ((match = tagRegex.exec(valStr)) !== null) { - if (match.index > lastIndex) { - valueSpan.createSpan({ text: valStr.substring(lastIndex, match.index) }); - } - const tagName = match[1]; - const tagEl = valueSpan.createEl('a', { - cls: 'tag', - text: '#' + tagName, - attr: { 'href': '#' + tagName } - }); - tagEl.addEventListener('click', (e) => { - e.preventDefault(); - (this.app as AppWithInternalPlugins).internalPlugins?.getPluginById?.('global-search')?.instance?.openGlobalSearch?.('tag:#' + tagName); - }); - lastIndex = tagRegex.lastIndex; - } - if (lastIndex < valStr.length) { - valueSpan.createSpan({ text: valStr.substring(lastIndex) }); - } - } - } else { - valueSpan.createSpan({ text: valStr }); - } - - if (index < values.length - 1) { - const isTag = key.toLowerCase() === 'tags' || key.toLowerCase() === 'tag'; - valueSpan.createSpan({ text: isTag ? ' ' : ', ' }); - } - }); - } - } - } - - // 創建筆記內容區域 - const noteContentArea = noteContent.createDiv('ge-note-content'); - - try { - // 讀取筆記內容 - const content = await this.app.vault.read(file); - - // 使用 Obsidian 的 MarkdownRenderer 渲染內容 - await MarkdownRenderer.render( - this.app, - content, - noteContentArea, - file.path, - this - ); - - // 加上自訂屬性 data-source-path - noteContentArea - .querySelectorAll('img') - .forEach((img) => (img.dataset.sourcePath = file.path)); - - // 處理內部連結點擊 - const handleLinkClick = (e: MouseEvent) => { - const target = e.target as HTMLElement; - const link = target.closest('a.internal-link'); - if (link) { - e.preventDefault(); - e.stopPropagation(); - - const href = link.getAttribute('href'); - if (href) { - const linkText = link.getAttribute('data-href') || href; - const linkedFile = this.app.metadataCache.getFirstLinkpathDest(linkText, file.path); - if (linkedFile) { - void this.getLeafByMode(linkedFile).openFile(linkedFile); - } - } - } - }; - - // 使用 registerDomEvent 註冊事件 - this.registerDomEvent(noteContentArea, 'click', handleLinkClick); - } catch (error) { - noteContentArea.textContent = '無法載入筆記內容'; - console.error('Error loading note content:', error); - } - - // 行動裝置下拉或上拉關閉筆記 - if (Platform.isMobile && this.noteViewContainer) { - let startY = 0; - let startX = 0; - let currentY = 0; - let isPulling = false; - let isPullingUp = false; - let isDragging = false; - let initialScrollTop = 0; - let isAtTop = false; - let isAtBottom = false; - - const handleTouchStart = (e: TouchEvent) => { - const target = e.target as HTMLElement; - // 避開按鈕、連結等可互動元素,以免影響正常點擊 - if (target.closest('button') || target.closest('a') || target.closest('input') || target.closest('textarea')) { - return; - } - - initialScrollTop = scrollContainer.scrollTop; - isAtTop = initialScrollTop <= 0; - // 考量誤差,留 1px 空間 - isAtBottom = initialScrollTop + scrollContainer.clientHeight >= scrollContainer.scrollHeight - 1; - - // 只有在筆記滾動到最頂部或最底部時才記錄起始位置 - if (isAtTop || isAtBottom) { - startY = e.touches[0].clientY; - currentY = startY; - startX = e.touches[0].clientX; - isPulling = true; - isDragging = false; - isPullingUp = false; - } - }; - - const handleTouchMove = (e: TouchEvent) => { - if (!isPulling || !this.noteViewContainer) return; - - currentY = e.touches[0].clientY; - const currentX = e.touches[0].clientX; - const deltaY = currentY - startY; - const deltaX = currentX - startX; - - // 如果還沒開始拖曳,先判斷滑動方向和距離 - if (!isDragging) { - // 水平滑動取消 - if (Math.abs(deltaX) > Math.abs(deltaY)) { - isPulling = false; - return; - } - - // 根據位置判斷是否允許滑動方向 - // 上拉關閉較容易和閱讀到底部後的自然滑動衝突,因此門檻比下拉高一點 - const pullDownStartThreshold = 24; - const pullUpStartThreshold = 36; - const canPullDown = isAtTop && deltaY > pullDownStartThreshold; - const canPullUp = isAtBottom && deltaY < -pullUpStartThreshold; - - if (canPullDown || canPullUp) { - isDragging = true; - // 當同時符合(例如內容很短)且往上拉時,視為上拉 - isPullingUp = canPullUp && deltaY < 0; - if (this.noteViewContainer) { - this.noteViewContainer.setCssProps({ transition: 'none' }); - } - } else if ((isAtTop && !isAtBottom && deltaY < 0) || (isAtBottom && !isAtTop && deltaY > 0)) { - // 往反方向正常滑動閱讀時,取消攔截 - isPulling = false; - return; - } - } - - // 在拖曳狀態下進行視窗移動 - if (isDragging) { - // 防止觸發瀏覽器預設滾動或下拉更新 - if (e.cancelable) { - e.preventDefault(); - } - const resistance = 0.5; - const translateY = deltaY * resistance; - this.noteViewContainer.setCssProps({ transform: `translateY(${translateY}px)` }); - } - }; - - const handleTouchEnd = () => { - if (!isPulling || !this.noteViewContainer) return; - isPulling = false; - - // 如果沒有真正拖曳,就當作一般點擊,不執行後續動畫 - if (!isDragging) return; - isDragging = false; - - const deltaY = currentY - startY; - - // 判斷下拉或上拉距離是否夠大 - // 上拉關閉提高門檻,避免閱讀到筆記底部時不小心觸發 - const closeThreshold = isPullingUp ? 170 : 110; - if ((!isPullingUp && deltaY > closeThreshold) || (isPullingUp && deltaY < -closeThreshold)) { - // 關閉筆記 - const targetY = isPullingUp ? '-100vh' : '100vh'; - this.noteViewContainer.setCssProps({ - transition: 'transform 0.2s ease-out', - transform: `translateY(${targetY})`, - }); - window.setTimeout(() => { - this.hideNoteInGrid(); - if (this.noteViewContainer) { - this.noteViewContainer.setCssProps({ - transform: '', - transition: '', - }); - } - }, 200); - } else { - // 距離不夠,彈回原位 - this.noteViewContainer.setCssProps({ - transition: 'transform 0.3s ease-out', - transform: 'translateY(0)', - }); - window.setTimeout(() => { - if (this.noteViewContainer) { - this.noteViewContainer.setCssProps({ - transform: '', - transition: '', - }); - } - }, 300); - } - }; - - this.noteViewContainer.addEventListener('touchstart', handleTouchStart, { passive: true }); - this.noteViewContainer.addEventListener('touchmove', handleTouchMove, { passive: false }); - this.noteViewContainer.addEventListener('touchend', handleTouchEnd); - } - - // 設定狀態 - this.isShowingNote = true; - - // 註冊鍵盤事件監聽器 - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - this.hideNoteInGrid(); - e.preventDefault(); - } - }; - - activeDocument.addEventListener('keydown', handleKeyDown); - - // 儲存事件監聽器以便後續移除 - (this.noteViewContainer as NoteViewContainerWithKeydownHandler).keydownHandler = handleKeyDown; + return this.previewManager.showNoteInGrid(file); } // 隱藏筆記顯示 hideNoteInGrid() { - if (!this.isShowingNote) return; + this.previewManager.hideNoteInGrid(); + } - // 顯示移動端導航欄 (僅在行動裝置上) - if (Platform.isPhone) { - const mobileNavbar = activeDocument.querySelector('.mobile-navbar') as HTMLElement; - if (mobileNavbar) { - mobileNavbar.setCssProps({ - transform: 'translateY(0)', - transition: 'transform 0.3s ease-in', - }); - } - } + // 在網格視圖中直接顯示 ZIP 圖片網格 + async showZipInGrid(file: TFile) { + return this.previewManager.showZipInGrid(file); + } - if (this.noteViewContainer) { - // 移除鍵盤事件監聽器 - const keydownHandler = (this.noteViewContainer as NoteViewContainerWithKeydownHandler).keydownHandler; - if (keydownHandler) { - activeDocument.removeEventListener('keydown', keydownHandler); - } + // 開啟 ZIP 內部圖片的 MediaModal + openZipMediaModal(index: number, gridEl: HTMLElement) { + this.previewManager.openZipMediaModal(index, gridEl); + } - this.noteViewContainer.remove(); - this.noteViewContainer = null; - } + // 隱藏 ZIP 顯示 + hideZipInGrid() { + this.previewManager.hideZipInGrid(); + } - this.isShowingNote = false; + selectZipItem(idx: number, gridEl: HTMLElement) { + this.previewManager.selectZipItem(idx, gridEl); } // 保存視圖狀態 diff --git a/src/ZipView.ts b/src/ZipView.ts new file mode 100644 index 0000000..2904530 --- /dev/null +++ b/src/ZipView.ts @@ -0,0 +1,416 @@ +import { FileView, WorkspaceLeaf, TFile } from 'obsidian'; +import JSZip from 'jszip'; +import { MediaModal, VirtualMediaFile } from './modal/mediaModal'; + +export const VIEW_TYPE_ZIP = 'zip-image-viewer-view'; + +export class ZipView extends FileView { + zip: JSZip | null = null; + imageFiles: string[] = []; + currentIndex: number = -1; + loading = false; + + // Grid view 狀態與資源 + thumbnailUrls: Map = new Map(); + observer: IntersectionObserver | null = null; + + // DOM Elements + container!: HTMLDivElement; + titleEl!: HTMLDivElement; + loadingEl!: HTMLDivElement; + gridContainer!: HTMLDivElement; + gridEl!: HTMLDivElement; + + constructor(leaf: WorkspaceLeaf) { + super(leaf); + } + + getViewType(): string { + return VIEW_TYPE_ZIP; + } + + getDisplayText(): string { + return this.file ? this.file.name : "Zip Image Viewer"; + } + + getIcon(): string { + return "folder-archive"; + } + + async onOpen() { + const container = this.containerEl.children[1] as HTMLDivElement; + container.empty(); + container.addClass('zip-viewer-container'); + container.addClass('is-grid-mode'); + container.setAttribute('tabindex', '0'); + + // 建立頂部控制列 + const header = container.createDiv({ cls: 'zip-viewer-header' }); + this.titleEl = header.createDiv({ cls: 'zip-viewer-title' }); + + // 主要區域 + const mainArea = container.createDiv({ cls: 'zip-viewer-main' }); + + // 載入狀態 + this.loadingEl = mainArea.createDiv({ cls: 'zip-viewer-loading' }); + this.loadingEl.setText("Loading zip content..."); + + // 網格檢視容器 + this.gridContainer = mainArea.createDiv({ cls: 'zip-viewer-grid-container' }); + this.gridEl = this.gridContainer.createDiv({ cls: 'zip-viewer-grid' }); + + this.container = container; + + + // 點擊容器自動聚焦 + this.registerDomEvent(container, 'click', () => { + container.focus(); + }); + + // 註冊鍵盤事件處理 + this.registerDomEvent(container, 'keydown', (event: KeyboardEvent) => { + this.handleKeyDown(event); + }); + } + + async onClose() { + this.cleanupThumbnails(); + } + + cleanupThumbnails() { + if (this.observer) { + this.observer.disconnect(); + this.observer = null; + } + this.thumbnailUrls.forEach(url => { + URL.revokeObjectURL(url); + }); + this.thumbnailUrls.clear(); + if (this.gridEl) { + this.gridEl.empty(); + } + } + + async onLoadFile(file: TFile) { + this.cleanupThumbnails(); + this.zip = null; + this.imageFiles = []; + this.currentIndex = -1; + this.loading = true; + + if (this.loadingEl) this.loadingEl.addClass('active'); + if (this.titleEl) this.titleEl.setText("Reading zip..."); + + try { + const arrayBuffer = await this.app.vault.readBinary(file); + const zip = await JSZip.loadAsync(arrayBuffer); + this.zip = zip; + + const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico']; + this.imageFiles = Object.keys(zip.files) + .filter(filename => { + const lower = filename.toLowerCase(); + return !zip.files[filename].dir && + imageExtensions.some(ext => lower.endsWith(ext)) && + !lower.includes('__macosx'); + }) + .sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' })); + + if (this.loadingEl) this.loadingEl.removeClass('active'); + + if (this.imageFiles.length === 0) { + if (this.titleEl) this.titleEl.setText("No images found in zip file"); + this.loading = false; + return; + } + + this.initGrid(); + + this.currentIndex = 0; + this.selectItem(0); + this.loading = false; + if (this.titleEl) { + this.titleEl.setText(`${file.name} (${this.imageFiles.length} 張圖片)`); + } + + // 自動聚焦,以利直接使用鍵盤 + if (this.container) { + this.container.focus(); + } + } catch (err) { + console.error("Error reading zip file:", err); + if (this.loadingEl) this.loadingEl.removeClass('active'); + if (this.titleEl) this.titleEl.setText("Error reading zip file"); + this.loading = false; + } + } + + initGrid() { + if (!this.gridEl) return; + this.gridEl.empty(); + + this.setupLazyLoading(); + + this.imageFiles.forEach((filename, index) => { + const displayName = filename.split('/').pop() || filename; + + const item = this.gridEl.createDiv({ cls: 'zip-viewer-grid-item' }); + item.setAttribute('data-index', index.toString()); + item.setAttribute('data-file-path', filename); + + const imgWrapper = item.createDiv({ cls: 'zip-viewer-grid-item-img-wrapper' }); + const img = imgWrapper.createEl('img'); + + const label = item.createDiv({ cls: 'zip-viewer-grid-item-label' }); + label.setText(displayName); + + item.addEventListener('click', (e) => { + e.stopPropagation(); + this.openMediaModal(index); + }); + + if (this.observer) { + this.observer.observe(img); + } + }); + } + + setupLazyLoading() { + if (this.observer) { + this.observer.disconnect(); + } + + this.observer = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + const img = entry.target as HTMLImageElement; + const item = img.closest('.zip-viewer-grid-item'); + if (!item) return; + + const indexStr = item.getAttribute('data-index'); + if (indexStr === null) return; + const index = parseInt(indexStr, 10); + + if (this.observer) { + this.observer.unobserve(img); + } + + if (this.thumbnailUrls.has(index)) { + img.src = this.thumbnailUrls.get(index)!; + img.addClass('lazy-loaded'); + return; + } + + if (!this.zip) return; + const filename = this.imageFiles[index]; + void (async () => { + try { + const fileObject = this.zip!.files[filename]; + const blob = await fileObject.async("blob"); + const url = URL.createObjectURL(blob); + this.thumbnailUrls.set(index, url); + + img.src = url; + img.addClass('lazy-loaded'); + } catch (err) { + console.error(`Error loading thumbnail for index ${index}:`, err); + } + })(); + } + }); + }, { + root: this.gridContainer, + rootMargin: '100px', + threshold: 0.01 + }); + } + + openMediaModal(index: number) { + if (!this.zip) return; + + // 建立虛擬 Media 檔案列表 + const virtualMediaFiles: VirtualMediaFile[] = this.imageFiles.map(filename => { + return { + name: filename.split('/').pop() || filename, + path: filename, + isVirtual: true, + getBlobUrl: async () => { + if (!this.zip) throw new Error("Zip is not loaded"); + const fileObject = this.zip.files[filename]; + const blob = await fileObject.async("blob"); + return URL.createObjectURL(blob); + } + }; + }); + + const activeVirtualFile = virtualMediaFiles[index]; + + // 為了相容 GridViewFocusTarget 的 interface + const focusTarget = { + gridItems: Array.from(this.gridEl.querySelectorAll('.zip-viewer-grid-item')), + hasKeyboardFocus: true, + selectItem: (idx: number) => { + this.selectItem(idx); + // 使 container 重新獲得鍵盤焦點 + if (this.container) { + this.container.focus(); + } + } + }; + + const modal = new MediaModal(this.app, activeVirtualFile, virtualMediaFiles, focusTarget); + modal.open(); + } + + selectItem(idx: number) { + if (idx < 0 || idx >= this.imageFiles.length) return; + this.currentIndex = idx; + const items = this.gridEl.querySelectorAll('.zip-viewer-grid-item'); + items.forEach((item, index) => { + if (index === idx) { + item.addClass('current'); + item.scrollIntoView({ block: 'center', behavior: 'smooth' }); + } else { + item.removeClass('current'); + } + }); + } + + handleKeyDown(event: KeyboardEvent) { + const target = event.target as HTMLElement | null; + if (target && ( + target.isContentEditable || + target.closest('input, textarea, select, [contenteditable="true"]') + )) { + return; + } + + if (this.imageFiles.length === 0 || this.loading) return; + + // 如果有 Modal 視窗,則不處理(讓 Modal 自己處理) + if (activeDocument.querySelector('.modal-container')) return; + + let newIndex = this.currentIndex; + + // 如果還沒有選中項目(currentIndex === -1)且按下了方向鍵,選中第一個項目 + if (this.currentIndex === -1 && + ['ArrowRight', 'ArrowLeft', 'ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) { + this.selectItem(0); + event.preventDefault(); + return; + } + + const gridItems = Array.from(this.gridEl.querySelectorAll('.zip-viewer-grid-item')); + + switch (event.key) { + case 'ArrowRight': + newIndex = Math.min(this.imageFiles.length - 1, this.currentIndex + 1); + event.preventDefault(); + break; + case 'ArrowLeft': + newIndex = Math.max(0, this.currentIndex - 1); + event.preventDefault(); + break; + case 'ArrowDown': + if (this.currentIndex >= 0 && gridItems.length > 0) { + const currentItem = gridItems[this.currentIndex]; + const currentRect = currentItem.getBoundingClientRect(); + const currentCenterX = currentRect.left + currentRect.width / 2; + const currentBottom = currentRect.bottom; + + let closestItem = -1; + let minDistance = Number.MAX_VALUE; + let minVerticalDistance = Number.MAX_VALUE; + + for (let i = 0; i < gridItems.length; i++) { + if (i === this.currentIndex) continue; + + const itemRect = gridItems[i].getBoundingClientRect(); + const itemCenterX = itemRect.left + itemRect.width / 2; + const itemTop = itemRect.top; + + if (itemTop <= currentBottom) continue; + + const horizontalDistance = Math.abs(itemCenterX - currentCenterX); + const verticalDistance = itemTop - currentBottom; + + if (verticalDistance < minVerticalDistance || + (verticalDistance === minVerticalDistance && horizontalDistance < minDistance)) { + minVerticalDistance = verticalDistance; + minDistance = horizontalDistance; + closestItem = i; + } + } + + if (closestItem !== -1) { + newIndex = closestItem; + } else { + newIndex = gridItems.length - 1; + } + } else { + newIndex = 0; + } + event.preventDefault(); + break; + case 'ArrowUp': + if (this.currentIndex >= 0 && gridItems.length > 0) { + const currentItem = gridItems[this.currentIndex]; + const currentRect = currentItem.getBoundingClientRect(); + const currentCenterX = currentRect.left + currentRect.width / 2; + const currentTop = currentRect.top; + + let closestItem = -1; + let minDistance = Number.MAX_VALUE; + let minVerticalDistance = Number.MAX_VALUE; + + for (let i = 0; i < gridItems.length; i++) { + if (i === this.currentIndex) continue; + + const itemRect = gridItems[i].getBoundingClientRect(); + const itemCenterX = itemRect.left + itemRect.width / 2; + const itemBottom = itemRect.bottom; + + if (itemBottom >= currentTop) continue; + + const horizontalDistance = Math.abs(itemCenterX - currentCenterX); + const verticalDistance = currentTop - itemBottom; + + if (verticalDistance < minVerticalDistance || + (verticalDistance === minVerticalDistance && horizontalDistance < minDistance)) { + minVerticalDistance = verticalDistance; + minDistance = horizontalDistance; + closestItem = i; + } + } + + if (closestItem !== -1) { + newIndex = closestItem; + } else { + newIndex = 0; + } + } else { + newIndex = 0; + } + event.preventDefault(); + break; + case 'Home': + newIndex = 0; + event.preventDefault(); + break; + case 'End': + newIndex = this.imageFiles.length - 1; + event.preventDefault(); + break; + case 'Enter': + if (this.currentIndex >= 0 && this.currentIndex < this.imageFiles.length) { + this.openMediaModal(this.currentIndex); + } + event.preventDefault(); + break; + } + + if (newIndex !== this.currentIndex) { + this.selectItem(newIndex); + } + } +} diff --git a/src/handleKeyDown.ts b/src/handleKeyDown.ts index c8c267d..116b3db 100644 --- a/src/handleKeyDown.ts +++ b/src/handleKeyDown.ts @@ -9,8 +9,8 @@ export function handleKeyDown(gridView: GridView, event: KeyboardEvent) { return; } - // 如果沒有項目或正在檢視筆記,直接返回 - if (gridView.gridItems.length === 0 || gridView.isShowingNote) return; + // 如果沒有項目、正在檢視筆記或正在檢視 ZIP,直接返回 + if (gridView.gridItems.length === 0 || gridView.isShowingNote || gridView.isShowingZip) return; // 如果有Modal視窗,直接返回 if (activeDocument.querySelector('.modal-container')) return; diff --git a/src/main.ts b/src/main.ts index c8c27f7..98377dd 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,7 @@ import { EventRef, Menu, MenuItem, Plugin, TFile, TFolder, WorkspaceLeaf } from 'obsidian'; import { GridView } from './GridView'; import { ExplorerView, EXPLORER_VIEW_TYPE } from './ExplorerView'; +import { ZipView, VIEW_TYPE_ZIP } from './ZipView'; import { updateCustomDocumentExtensions, isMediaFile } from './utils/fileUtils'; import { showFolderSelectionModal } from './modal/folderSelectionModal'; import { showNoteSettingsModal } from './modal/noteSettingsModal'; @@ -57,6 +58,13 @@ export default class GridExplorerPlugin extends Plugin { (leaf) => new ExplorerView(leaf, this) ); + // 註冊 ZIP 檢視視圖 + this.registerView( + VIEW_TYPE_ZIP, + (leaf) => new ZipView(leaf) + ); + this.registerExtensions(["zip"], VIEW_TYPE_ZIP); + // 註冊設定頁面 this.addSettingTab(new GridExplorerSettingTab(this.app, this)); diff --git a/src/modal/mediaModal.ts b/src/modal/mediaModal.ts index e68dd79..0f8f7a2 100644 --- a/src/modal/mediaModal.ts +++ b/src/modal/mediaModal.ts @@ -1,6 +1,48 @@ -import { App, Modal, TFile, Menu, setIcon } from 'obsidian'; +import { App, Modal, TFile, Menu, setIcon, Platform } from 'obsidian'; import { isImageFile, isVideoFile, isAudioFile } from '../utils/fileUtils'; +export interface VirtualMediaFile { + name: string; + path: string; + isVirtual: true; + getBlobUrl: () => Promise; +} + +export type MediaFile = TFile | VirtualMediaFile; + +export function isImage(file: MediaFile): boolean { + if ('isVirtual' in file && file.isVirtual) { + const ext = file.name.split('.').pop()?.toLowerCase() || ''; + return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'bmp', 'svg'].includes(ext); + } + if (file instanceof TFile) { + return isImageFile(file); + } + return false; +} + +export function isVideo(file: MediaFile): boolean { + if ('isVirtual' in file && file.isVirtual) { + const ext = file.name.split('.').pop()?.toLowerCase() || ''; + return ['mp4', 'webm', 'mov', 'avi', 'mkv', 'ogv'].includes(ext); + } + if (file instanceof TFile) { + return isVideoFile(file); + } + return false; +} + +export function isAudio(file: MediaFile): boolean { + if ('isVirtual' in file && file.isVirtual) { + const ext = file.name.split('.').pop()?.toLowerCase() || ''; + return ['flac', 'm4a', 'mp3', 'ogg', 'wav', '3gp'].includes(ext); + } + if (file instanceof TFile) { + return isAudioFile(file); + } + return false; +} + interface GridViewFocusTarget { gridItems: HTMLElement[]; hasKeyboardFocus: boolean; @@ -8,8 +50,8 @@ interface GridViewFocusTarget { } export class MediaModal extends Modal { - private file: TFile; - private mediaFiles: TFile[]; + private file: MediaFile; + private mediaFiles: MediaFile[]; private currentIndex: number; private currentMediaElement: HTMLElement | null = null; private isZoomed = false; @@ -24,7 +66,7 @@ export class MediaModal extends Modal { private minSwipeDistance = 50; // 最小滑動距離 private maxSwipeTime = 300; // 最大滑動時間(毫秒) - constructor(app: App, file: TFile, mediaFiles: TFile[], gridView?: GridViewFocusTarget) { + constructor(app: App, file: MediaFile, mediaFiles: MediaFile[], gridView?: GridViewFocusTarget) { super(app); this.file = file; this.mediaFiles = mediaFiles; @@ -103,6 +145,28 @@ export class MediaModal extends Modal { return false; }); + this.scope.register(null, 'Home', () => { + this.showMediaAtIndex(0); + return false; + }); + + this.scope.register(null, 'End', () => { + this.showMediaAtIndex(this.mediaFiles.length - 1); + return false; + }); + + this.scope.register(null, 'PageUp', () => { + const prevIndex = Math.max(0, this.currentIndex - 5); + this.showMediaAtIndex(prevIndex); + return false; + }); + + this.scope.register(null, 'PageDown', () => { + const nextIndex = Math.min(this.mediaFiles.length - 1, this.currentIndex + 5); + this.showMediaAtIndex(nextIndex); + return false; + }); + // 註冊觸控事件(行動裝置拖曳翻頁) this.registerTouchEvents(this.contentEl); @@ -173,12 +237,11 @@ export class MediaModal extends Modal { const mediaFile = this.mediaFiles[index]; - if (isImageFile(mediaFile)) { + if (isImage(mediaFile)) { // 創建圖片元素 const img = activeDocument.createElement('img'); img.className = 'ge-fullscreen-image'; img.addClass('ge-hidden'); // 先隱藏新圖片 - img.src = this.app.vault.getResourcePath(mediaFile); // 等待新圖片載入完成 img.onload = () => { @@ -193,15 +256,30 @@ export class MediaModal extends Modal { img.removeClass('ge-hidden'); }; + if ('isVirtual' in mediaFile && mediaFile.isVirtual) { + mediaFile.getBlobUrl().then(url => { + img.src = url; + }).catch(err => console.error("Error loading virtual image:", err)); + } else if (mediaFile instanceof TFile) { + img.src = this.app.vault.getResourcePath(mediaFile); + } + mediaContainer.appendChild(img); // 圖片點擊事件(放大/縮小) - img.addEventListener('click', (event) => { - event.stopPropagation(); - this.toggleImageZoom(img, event); - }); + if (Platform.isMobile) { + img.addEventListener('dblclick', (event) => { + event.stopPropagation(); + this.toggleImageZoom(img, event); + }); + } else { + img.addEventListener('click', (event) => { + event.stopPropagation(); + this.toggleImageZoom(img, event); + }); + } - } else if (isVideoFile(mediaFile) || isAudioFile(mediaFile)) { + } else if (isVideo(mediaFile) || isAudio(mediaFile)) { // 對於影片和音樂,維持原有的處理方式 if (this.currentMediaElement) { this.currentMediaElement.remove(); @@ -210,7 +288,14 @@ export class MediaModal extends Modal { video.className = 'ge-fullscreen-video'; video.controls = true; video.autoplay = true; - video.src = this.app.vault.getResourcePath(mediaFile); + + if ('isVirtual' in mediaFile && mediaFile.isVirtual) { + mediaFile.getBlobUrl().then(url => { + video.src = url; + }).catch(err => console.error("Error loading virtual video/audio:", err)); + } else if (mediaFile instanceof TFile) { + video.src = this.app.vault.getResourcePath(mediaFile); + } mediaContainer.appendChild(video); this.currentMediaElement = video; @@ -221,7 +306,7 @@ export class MediaModal extends Modal { oldFileNameElement.remove(); } - if (isAudioFile(mediaFile)) { + if (isAudio(mediaFile)) { //顯示檔案名稱 const fileName = mediaFile.name; const fileNameElement = activeDocument.createElement('div'); @@ -382,10 +467,15 @@ export class MediaModal extends Modal { } // 處理媒體右鍵選單 - private onMediaContextMenu(event: MouseEvent, file: TFile) { + private onMediaContextMenu(event: MouseEvent, file: MediaFile) { + if ('isVirtual' in file && file.isVirtual) { + return; + } event.preventDefault(); const menu = new Menu(); - this.app.workspace.trigger('file-menu', menu, file, 'media-viewer'); + if (file instanceof TFile) { + this.app.workspace.trigger('file-menu', menu, file, 'media-viewer'); + } menu.showAtMouseEvent(event); } diff --git a/src/renderModePath.ts b/src/renderModePath.ts index 32c745b..5586564 100644 --- a/src/renderModePath.ts +++ b/src/renderModePath.ts @@ -114,8 +114,11 @@ export function renderModePath(gridView: GridView) { // 為區域添加點擊事件,點擊後網格容器捲動到最頂部 modenameContainer.addEventListener('click', (event: MouseEvent) => { - // 只有當點擊的是頂部按鈕區域本身(而不是其中的按鈕)時才觸發捲動 - if (event.target === modenameContainer) { + const target = event.target as HTMLElement; + // 只有當點擊的不是連結、搜尋框或其它可點擊的按鈕時,才觸發捲動 + const isInteractive = target.closest('a') || + target.closest('.ge-search-text-container'); + if (!isInteractive) { event.preventDefault(); // 取得網格容器 const gridContainer = gridView.containerEl.querySelector('.ge-grid-container'); diff --git a/src/settings.ts b/src/settings.ts index 1e0f081..b765357 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -869,7 +869,7 @@ export class GridExplorerSettingTab extends PluginSettingTab { .setValue(this.plugin.settings.customDocumentExtensions) .onChange(async (value) => { this.plugin.settings.customDocumentExtensions = value; - await this.plugin.saveSettings(); + await this.plugin.saveSettings(false); }); }); diff --git a/src/translations/en.ts b/src/translations/en.ts index 0d17f5e..decb3e1 100644 --- a/src/translations/en.ts +++ b/src/translations/en.ts @@ -312,6 +312,13 @@ export default { 'open_quick_access_folder': 'Open quick access folder', 'open_quick_access_mode': 'Open quick access mode', + // ZIP Viewer + 'zip_open_file': 'Open ZIP file', + 'zip_close_view': 'Close ZIP view', + 'zip_loading': 'Reading ZIP file...', + 'zip_no_images': 'No images found in ZIP file', + 'zip_load_error': 'Unable to load ZIP file content', + // Explorer Stash 'stash': 'Stash', 'drop_files_here': 'Drop files here to stash', diff --git a/src/translations/ja.ts b/src/translations/ja.ts index 4d2d2a2..2d8473d 100644 --- a/src/translations/ja.ts +++ b/src/translations/ja.ts @@ -311,6 +311,13 @@ export default { 'open_quick_access_folder': 'クイックアクセス フォルダーを開く', 'open_quick_access_mode': 'クイックアクセスモードを開く', + // ZIP Viewer + 'zip_open_file': 'ZIPファイルを開く', + 'zip_close_view': 'ZIPビューを閉じる', + 'zip_loading': 'ZIPファイルを読み込み中...', + 'zip_no_images': 'ZIPファイル内に画像が見つかりません', + 'zip_load_error': 'ZIPファイルの内容を読み込めません', + // Explorer Stash 'stash': '一時保存', 'drop_files_here': 'ここにドラッグして一時保存', diff --git a/src/translations/ko.ts b/src/translations/ko.ts index ed0353e..e41b709 100644 --- a/src/translations/ko.ts +++ b/src/translations/ko.ts @@ -312,6 +312,13 @@ export default { 'open_quick_access_folder': '빠른 액세스 폴더 열기', 'open_quick_access_mode': '빠른 액세스 모드 열기', + // ZIP Viewer + 'zip_open_file': 'ZIP 파일 열기', + 'zip_close_view': 'ZIP 보기 닫기', + 'zip_loading': 'ZIP 파일 읽는 중...', + 'zip_no_images': 'ZIP 파일에서 이미지를 찾을 수 없습니다', + 'zip_load_error': 'ZIP 파일 내용을 불러올 수 없습니다', + // Explorer Stash 'stash': '보관함', 'drop_files_here': '파일을 여기에 드롭하여 보관', diff --git a/src/translations/ru.ts b/src/translations/ru.ts index c2a651d..f30b637 100644 --- a/src/translations/ru.ts +++ b/src/translations/ru.ts @@ -311,6 +311,13 @@ export default { 'open_quick_access_folder': 'Открыть папку быстрого доступа', 'open_quick_access_mode': 'Открыть быстрый доступ по режиму', + // ZIP Viewer + 'zip_open_file': 'Открыть ZIP-файл', + 'zip_close_view': 'Закрыть ZIP-просмотр', + 'zip_loading': 'Чтение ZIP-файла...', + 'zip_no_images': 'В ZIP-файле не найдены изображения', + 'zip_load_error': 'Не удалось загрузить содержимое ZIP-файла', + // Explorer Stash 'stash': 'Хранилище', 'drop_files_here': 'Перетащите файлы сюда, чтобы сохранить', diff --git a/src/translations/uk.ts b/src/translations/uk.ts index a3f4b65..58fd352 100644 --- a/src/translations/uk.ts +++ b/src/translations/uk.ts @@ -311,6 +311,13 @@ export default { 'open_quick_access_folder': 'Відкрити папку швидкого доступу', 'open_quick_access_mode': 'Відкрити режим швидкого доступу', + // ZIP Viewer + 'zip_open_file': 'Відкрити ZIP-файл', + 'zip_close_view': 'Закрити ZIP-перегляд', + 'zip_loading': 'Читання ZIP-файлу...', + 'zip_no_images': 'У ZIP-файлі не знайдено зображень', + 'zip_load_error': 'Не вдалося завантажити вміст ZIP-файлу', + // Explorer Stash 'stash': 'Сховище', 'drop_files_here': 'Перетягніть файли сюди для зберігання', diff --git a/src/translations/zh-TW.ts b/src/translations/zh-TW.ts index fd3fcfe..9db25ef 100644 --- a/src/translations/zh-TW.ts +++ b/src/translations/zh-TW.ts @@ -312,6 +312,13 @@ export default { 'open_quick_access_folder': '開啟快速存取資料夾', 'open_quick_access_mode': '開啟快速存取模式', + // ZIP 檢視器 + 'zip_open_file': '開啟 ZIP 檔案', + 'zip_close_view': '關閉 ZIP 檢視', + 'zip_loading': '正在讀取 ZIP 檔案...', + 'zip_no_images': 'ZIP 檔案中找不到圖片', + 'zip_load_error': '無法載入 ZIP 檔案內容', + // Explorer Stash 'stash': '暫存區', 'drop_files_here': '拖曳檔案到此暫存', diff --git a/src/translations/zh.ts b/src/translations/zh.ts index aff5a56..6f438ef 100644 --- a/src/translations/zh.ts +++ b/src/translations/zh.ts @@ -311,6 +311,13 @@ export default { 'open_quick_access_folder': '打开快速访问文件夹', 'open_quick_access_mode': '打开快速访问模式', + // ZIP 查看器 + 'zip_open_file': '打开 ZIP 文件', + 'zip_close_view': '关闭 ZIP 查看', + 'zip_loading': '正在读取 ZIP 文件...', + 'zip_no_images': 'ZIP 文件中找不到图片', + 'zip_load_error': '无法加载 ZIP 文件内容', + // Explorer Stash 'stash': '暂存区', 'drop_files_here': '拖曳文件到此暂存', diff --git a/src/utils/fileUtils.ts b/src/utils/fileUtils.ts index 0f41811..0de6b59 100644 --- a/src/utils/fileUtils.ts +++ b/src/utils/fileUtils.ts @@ -18,7 +18,7 @@ declare module 'obsidian' { export const IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'bmp', 'svg']); export const VIDEO_EXTENSIONS = new Set(['mp4', 'webm', 'mov', 'avi', 'mkv', 'ogv']); export const AUDIO_EXTENSIONS = new Set(['flac', 'm4a', 'mp3', 'ogg', 'wav', '3gp']); -export const DOCUMENT_EXTENSIONS = new Set(['md', 'pdf', 'canvas', 'base']); +export const DOCUMENT_EXTENSIONS = new Set(['md', 'pdf', 'canvas', 'base', 'zip']); let customDocumentExtensions: string[] = []; diff --git a/src/utils/mediaUtils.ts b/src/utils/mediaUtils.ts index 9d7bc85..1570d5c 100644 --- a/src/utils/mediaUtils.ts +++ b/src/utils/mediaUtils.ts @@ -1,11 +1,79 @@ import { App, TFile, requestUrl, RequestUrlResponse } from 'obsidian'; +import JSZip from 'jszip'; +import { type GallerySettings } from '../settings'; + +type GridExplorerPlugin = { + settings?: Pick; +}; + +type AppPluginAccess = { + plugins?: { + plugins?: Record; + }; +}; + +// 尋找 zip 檔案內的第一張圖片並回傳它的 base64 Data URL +export async function getFirstImageFromZip(app: App, file: TFile): Promise { + try { + const arrayBuffer = await app.vault.readBinary(file); + const zip = await JSZip.loadAsync(arrayBuffer); + + const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'bmp', 'svg']; + let imageFileName: string | null = null; + + const filenames = Object.keys(zip.files).sort(); + for (const filename of filenames) { + const fileEntry = zip.files[filename]; + if (fileEntry.dir) continue; + if (filename.includes('__MACOSX')) continue; + + const ext = filename.split('.').pop()?.toLowerCase(); + if (ext && imageExtensions.includes(ext)) { + imageFileName = filename; + break; + } + } + + if (imageFileName) { + const fileData = zip.files[imageFileName]; + const ext = imageFileName.split('.').pop()?.toLowerCase() || 'jpeg'; + + let mimeType = 'image/jpeg'; + if (ext === 'png') mimeType = 'image/png'; + else if (ext === 'gif') mimeType = 'image/gif'; + else if (ext === 'webp') mimeType = 'image/webp'; + else if (ext === 'avif') mimeType = 'image/avif'; + else if (ext === 'bmp') mimeType = 'image/bmp'; + else if (ext === 'svg') mimeType = 'image/svg+xml'; + + const base64Data = await fileData.async('base64'); + return `data:${mimeType};base64,${base64Data}`; + } + + return null; + } catch (error) { + console.error('Error reading zip file:', file.path, error); + return null; + } +} // 尋找筆記中的第一張圖片 export async function findFirstImageInNote(app: App, content: string): Promise { try { - 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 pluginAccess = app as unknown as AppPluginAccess; + const customDocumentExtensions = pluginAccess.plugins?.plugins?.['obsidian-gridexplorer']?.settings?.customDocumentExtensions; + const isZipEnabled = customDocumentExtensions + ?.split(',') + .map((ext: string) => ext.trim().toLowerCase()) + .includes('zip'); + + const allowedExtensions = isZipEnabled + ? 'jpg|jpeg|png|gif|webp|avif|zip' + : 'jpg|jpeg|png|gif|webp|avif'; + + const internalStyle = new RegExp(`!?\\[\\[(.*?\\.(?:${allowedExtensions}))(?:\\|.*?)?\\]\\]`); + const markdownStyle = new RegExp(`!\\[(.*?)\\]\\(\\s*(\\S+?(?:\\.(?:${allowedExtensions})|format=(?:jpg|jpeg|png|gif|webp))[^\\s)]*)\\s*(?:\\s+["'][^"']*["'])?\\s*\\)`); + const frontmatterUrl = new RegExp(`^[\\w\\-_]+:\\s*(https?:\\/\\/\\S+?(?:\\.(?:${allowedExtensions})|format=(?:jpg|jpeg|png|gif|webp))[^\\s]*)\\s*$`); const combinedPatternSource = `(?:${internalStyle.source}|${markdownStyle.source}|${frontmatterUrl.source})`; const initialPattern = new RegExp(combinedPatternSource, 'im'); @@ -55,6 +123,9 @@ async function processMediaLink(app: App, internalMatch: RegExpMatchArray | RegE if (internalMatch[1]) { const file = app.metadataCache.getFirstLinkpathDest(internalMatch[1], ''); if (file) { + if (file.extension.toLowerCase() === 'zip') { + return await getFirstImageFromZip(app, file); + } return app.vault.getResourcePath(file); } } @@ -69,9 +140,15 @@ async function processMediaLink(app: App, internalMatch: RegExpMatchArray | RegE if (!file) { const fileByPath = app.vault.getAbstractFileByPath(url); if (fileByPath instanceof TFile) { + if (fileByPath.extension.toLowerCase() === 'zip') { + return await getFirstImageFromZip(app, fileByPath); + } return app.vault.getResourcePath(fileByPath); } } else { + if (file.extension.toLowerCase() === 'zip') { + return await getFirstImageFromZip(app, file); + } return app.vault.getResourcePath(file); } } @@ -91,11 +168,17 @@ async function resolveUrl(app: App, url: string): Promise { const file = app.metadataCache.getFirstLinkpathDest(url, ''); if (file) { + if (file.extension.toLowerCase() === 'zip') { + return await getFirstImageFromZip(app, file); + } return app.vault.getResourcePath(file); } const fileByPath = app.vault.getAbstractFileByPath(url); if (fileByPath instanceof TFile) { + if (fileByPath.extension.toLowerCase() === 'zip') { + return await getFirstImageFromZip(app, fileByPath); + } return app.vault.getResourcePath(fileByPath); } diff --git a/styles.css b/styles.css index 917fa08..edafa91 100644 --- a/styles.css +++ b/styles.css @@ -57,22 +57,22 @@ height: 100%; } -/* 右上角懸浮開啟筆記按鈕 Hover Open-in-Grid Button */ -/* .ge-grid-item { position: relative; } +/* 右上角懸浮開啟筆記按鈕 Hover Open-in-Grid Button +.ge-grid-item { position: relative; } .ge-hover-open-note { position: absolute; top: 6px; right: 6px; width: 24px; height: 24px; - border-radius: 50%; - background: rgba(0, 0, 0, 0.55); + border-radius: 4px; + background: transparent; border: 0; display: flex; align-items: center; justify-content: center; - color: #fff; - box-shadow: 0 1px 2px rgba(0,0,0,0.1); + background: var(--background-secondary); + color: var(--text-muted); opacity: 0; transition: opacity 0.15s ease, background-color 0.15s ease, color 0.15s ease; pointer-events: none; @@ -85,8 +85,8 @@ } .ge-hover-open-note:hover { - background: rgba(0, 0, 0, 0.75); - color: #fff; + background: var(--background-modifier-hover); + color: var(--text-normal); } .ge-hover-open-note svg { @@ -388,7 +388,7 @@ /* 選中項目的樣式 Selected Item */ .ge-selected-item { - outline: 1px solid var(--vault-profile-color-hover) !important; + outline: 1px solid var(--interactive-accent) !important; outline-offset: -1px; position: relative; z-index: 1; @@ -2859,8 +2859,8 @@ a.ge-current-folder:hover { } /* 移除 Obsidian 預設為 .view-content 添加的 margin-top, - 因為 GridExplorer 的 header buttons 位在 view-content 之外, - 這會導致 buttons 與 grid 之間出現多餘的空隙。 */ + 因為 GridExplorer 的 header buttons 位在 view-content 之外, + 這會導致 buttons 與 grid 之間出現多餘的空隙。 */ .is-phone .ge-grid-view-container .view-content { margin-top: 0 !important; padding-bottom: calc(var(--view-bottom-spacing) + var(--size-4-3)) !important; @@ -2877,3 +2877,322 @@ a.ge-current-folder:hover { .is-tablet .ge-note-content { padding-bottom: var(--safe-area-inset-bottom) !important; } + +/* =========================================================================== */ +/* Zip 圖片檢視視圖樣式 Zip Image Viewer Styles */ +/* =========================================================================== */ + +/* 移除 view-content 的內距,使 zip viewer 滿版 */ +.workspace-leaf:has(.zip-viewer-container) .view-content { + padding: 0 !important; + overflow: hidden !important; +} + +.zip-viewer-container { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + overflow: hidden; + position: relative; + background-color: var(--background-primary); + outline: none; +} + +/* 1. Grid 畫面下不用顯示上方工具列 */ +.zip-viewer-container.is-grid-mode .zip-viewer-header { + display: none; +} + +.zip-viewer-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 16px; + border-bottom: 1px solid var(--background-modifier-border); + background-color: var(--background-secondary); + flex-shrink: 0; +} + +.zip-viewer-title { + font-weight: 500; + color: var(--text-normal); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 70%; +} + +.zip-viewer-header-right { + display: flex; + align-items: center; + gap: 12px; +} + +.zip-viewer-btn { + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 6px; + border-radius: 4px; + color: var(--text-muted); + transition: background-color 0.2s, color 0.2s; +} + +.zip-viewer-btn:hover { + background-color: var(--background-modifier-hover); + color: var(--text-normal); +} + +.zip-viewer-counter { + color: var(--text-muted); + font-size: 0.9em; + user-select: none; +} + +.zip-viewer-main { + position: relative; + flex-grow: 1; + width: 100%; + height: 100%; + overflow: hidden; + display: flex; + justify-content: center; + align-items: center; +} + +.zip-viewer-loading { + display: none; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: var(--text-muted); + font-size: 1.1em; + z-index: 5; +} + +.zip-viewer-loading.active { + display: block; +} + + +.zip-viewer-grid-container { + display: none; + width: 100%; + height: 100%; + overflow-y: auto; + padding: 16px; + background-color: var(--background-primary); +} + +.zip-viewer-container.is-grid-mode .zip-viewer-grid-container { + display: block; +} + +.zip-viewer-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 16px; + width: 100%; +} + +.zip-viewer-grid-item { + display: flex; + flex-direction: column; + border: 1px solid var(--background-modifier-border); + border-radius: 6px; + overflow: hidden; + background-color: var(--background-secondary); + cursor: pointer; + transition: transform 0.2s, border-color 0.2s, box-shadow 0.2s; +} + +.zip-viewer-grid-item:hover { + transform: translateY(-2px); + border-color: var(--interactive-accent); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.zip-viewer-grid-item.current { + border-color: var(--interactive-accent); + box-shadow: 0 0 0 2px var(--interactive-accent); +} + +.zip-viewer-grid-item-img-wrapper { + width: 100%; + aspect-ratio: 1; + overflow: hidden; + background-color: var(--background-primary); + display: flex; + justify-content: center; + align-items: center; + position: relative; +} + +.zip-viewer-grid-item-img-wrapper img { + width: 100%; + height: 100%; + object-fit: cover; + opacity: 0; + transition: opacity 0.3s ease; +} + +.zip-viewer-grid-item-img-wrapper img.lazy-loaded { + opacity: 1; +} + +.zip-viewer-grid-item-label { + padding: 8px; + font-size: 0.85em; + color: var(--text-muted); + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + border-top: 1px solid var(--background-modifier-border); +} + +/* =========================================================================== */ +/* Zip 檔案類型在 GridView 與 Explorer 中的圖示顏色 */ +/* =========================================================================== */ +.ge-icon-container.ge-zip { + color: var(--color-yellow); +} +.ge-explorer-stash-item .ge-explorer-folder-icon.ge-zip { + color: var(--color-yellow); +} + +/* 網格預覽安全區域適配,確保橫屏不遮擋圖片,底部不遮擋 Home indicator */ + +.is-phone .zip-viewer-container { + padding-left: var(--safe-area-inset-left); + padding-right: var(--safe-area-inset-right); +} + +/* 確保網格滾動最底部時不被底部 Home Indicator 遮擋 */ +.is-phone .zip-viewer-grid-container { + padding-bottom: calc(var(--view-bottom-spacing) + var(--size-4-3)) !important; +} + +.is-tablet .zip-viewer-grid-container { + padding-bottom: calc(var(--safe-area-inset-bottom) + var(--size-4-3)) !important; +} + +/* =========================================================================== */ +/* GridView 直接顯示 ZIP 圖片網格樣式 */ +/* =========================================================================== */ + +.ge-zip-view-container { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: var(--background-primary); + z-index: 100; + display: flex; + flex-direction: column; +} + +.is-phone .ge-zip-view-container { + padding-top: var(--safe-area-inset-top); + padding-left: var(--safe-area-inset-left); + padding-right: var(--safe-area-inset-right); +} + +.ge-zip-top-bar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 9px 0; + gap: 10px; + background: var(--background-primary); + border-bottom: 1px solid var(--background-modifier-border); +} + +.ge-zip-top-left { + display: flex; + align-items: center; + gap: 8px; + padding-left: 20px; + flex: 1; + min-width: 0; +} + +.ge-zip-top-right { + display: flex; + align-items: center; + gap: 8px; + padding-right: 15px; +} + +.ge-zip-title { + font-size: 1em; + font-weight: bold; + color: var(--text-normal); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ge-zip-close-button, +.ge-zip-open-button { + display: flex; + align-items: center; + gap: 4px; + padding: 6px 12px; + background-color: var(--interactive-normal); + border-radius: var(--button-radius); + color: var(--text-normal); + font-size: 14px; + cursor: pointer; + transition: background-color 0.2s, transform 0.1s; + border: none; +} + +.ge-zip-close-button:hover, +.ge-zip-open-button:hover { + background-color: var(--interactive-hover); + transform: translateY(-1px); +} + +.ge-zip-scroll-container { + flex: 1 1 auto; + overflow: auto; + background-color: var(--background-primary); +} + +.ge-zip-sidebar-scroll-container { + background-color: var(--background-secondary); +} + +.ge-zip-content-container { + padding: 20px; +} + +.is-phone .ge-zip-content-container { + padding-bottom: var(--view-bottom-spacing) !important; +} + +.is-tablet .ge-zip-content-container { + padding-bottom: var(--safe-area-inset-bottom) !important; +} + +.ge-zip-sidebar-content-container { + padding: 15px; +} + +.ge-zip-loading, +.ge-zip-empty { + text-align: center; + padding: 40px; + color: var(--text-muted); + font-size: 1.1em; +} + +.is-mobile .ge-zip-view-container::-webkit-scrollbar { + display: none !important; + width: 0 !important; +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 3af0cc8..54018dd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,6 +10,7 @@ "importHelpers": true, "isolatedModules": true, "strictNullChecks": true, + "esModuleInterop": true, "lib": [ "DOM", "ES2020",