This commit is contained in:
Devon22 2026-06-08 01:56:16 +08:00
parent c36c95d3d5
commit e0c5da5fcf
26 changed files with 2282 additions and 527 deletions

View file

@ -66,6 +66,7 @@ Find "GridExplorer" in the Community plugins through the Obsidian app.
- `pdf`
- `canvas`
- `base`
- `zip`
## Folder Notes

View file

@ -66,6 +66,7 @@ Obsidian アプリのコミュニティプラグインから「GridExplorer」
- `pdf`
- `canvas`
- `base`
- `zip`
## フォルダノート

View file

@ -66,6 +66,7 @@
- `pdf`
- `canvas`
- `base`
- `zip`
## 資料夾筆記

View file

@ -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
View file

@ -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",

View file

@ -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"
}
}

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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
View 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);
}
}
}

View file

@ -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;

View file

@ -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));

View file

@ -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);
}

View file

@ -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');

View file

@ -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);
});
});

View file

@ -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',

View file

@ -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': 'ここにドラッグして一時保存',

View file

@ -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': '파일을 여기에 드롭하여 보관',

View file

@ -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': 'Перетащите файлы сюда, чтобы сохранить',

View file

@ -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': 'Перетягніть файли сюди для зберігання',

View file

@ -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': '拖曳檔案到此暫存',

View file

@ -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': '拖曳文件到此暂存',

View file

@ -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[] = [];

View file

@ -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);
}

View file

@ -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;
}

View file

@ -10,6 +10,7 @@
"importHelpers": true,
"isolatedModules": true,
"strictNullChecks": true,
"esModuleInterop": true,
"lib": [
"DOM",
"ES2020",