mirror of
https://github.com/devon22/obsidian-gridexplorer.git
synced 2026-07-22 05:38:16 +00:00
3.3.0
This commit is contained in:
parent
c36c95d3d5
commit
e0c5da5fcf
26 changed files with 2282 additions and 527 deletions
|
|
@ -66,6 +66,7 @@ Find "GridExplorer" in the Community plugins through the Obsidian app.
|
|||
- `pdf`
|
||||
- `canvas`
|
||||
- `base`
|
||||
- `zip`
|
||||
|
||||
## Folder Notes
|
||||
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ Obsidian アプリのコミュニティプラグインから「GridExplorer」
|
|||
- `pdf`
|
||||
- `canvas`
|
||||
- `base`
|
||||
- `zip`
|
||||
|
||||
## フォルダノート
|
||||
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@
|
|||
- `pdf`
|
||||
- `canvas`
|
||||
- `base`
|
||||
- `zip`
|
||||
|
||||
|
||||
## 資料夾筆記
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
123
package-lock.json
generated
123
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
1073
src/GridPreviewManager.ts
Normal file
1073
src/GridPreviewManager.ts
Normal file
File diff suppressed because it is too large
Load diff
560
src/GridView.ts
560
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<number, string> = 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<HTMLImageElement>('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);
|
||||
}
|
||||
|
||||
// 保存視圖狀態
|
||||
|
|
|
|||
416
src/ZipView.ts
Normal file
416
src/ZipView.ts
Normal file
|
|
@ -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<number, string> = 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<HTMLElement>('.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<HTMLElement>('.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string>;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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': 'ここにドラッグして一時保存',
|
||||
|
|
|
|||
|
|
@ -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': '파일을 여기에 드롭하여 보관',
|
||||
|
|
|
|||
|
|
@ -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': 'Перетащите файлы сюда, чтобы сохранить',
|
||||
|
|
|
|||
|
|
@ -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': 'Перетягніть файли сюди для зберігання',
|
||||
|
|
|
|||
|
|
@ -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': '拖曳檔案到此暫存',
|
||||
|
|
|
|||
|
|
@ -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': '拖曳文件到此暂存',
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,79 @@
|
|||
import { App, TFile, requestUrl, RequestUrlResponse } from 'obsidian';
|
||||
import JSZip from 'jszip';
|
||||
import { type GallerySettings } from '../settings';
|
||||
|
||||
type GridExplorerPlugin = {
|
||||
settings?: Pick<GallerySettings, 'customDocumentExtensions'>;
|
||||
};
|
||||
|
||||
type AppPluginAccess = {
|
||||
plugins?: {
|
||||
plugins?: Record<string, GridExplorerPlugin>;
|
||||
};
|
||||
};
|
||||
|
||||
// 尋找 zip 檔案內的第一張圖片並回傳它的 base64 Data URL
|
||||
export async function getFirstImageFromZip(app: App, file: TFile): Promise<string | null> {
|
||||
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<string | null> {
|
||||
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<string | null> {
|
|||
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
|
|||
341
styles.css
341
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;
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@
|
|||
"importHelpers": true,
|
||||
"isolatedModules": true,
|
||||
"strictNullChecks": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": [
|
||||
"DOM",
|
||||
"ES2020",
|
||||
|
|
|
|||
Loading…
Reference in a new issue