Compare commits

...

8 commits
3.4.11 ... main

Author SHA1 Message Date
Devon22
5d93b16e8d 3.4.19 2026-07-21 21:36:38 +08:00
Devon22
ce13321edd 3.4.18 2026-07-09 18:24:02 +08:00
Devon22
f031515fda 3.4.17 2026-07-07 23:07:34 +08:00
Devon22
f448ffb986 3.4.16 2026-07-06 00:13:43 +08:00
Devon22
9602dc2c75 3.4.15 2026-07-04 22:55:19 +08:00
Devon22
8a6a48f280 3.4.14 2026-07-02 23:47:18 +08:00
Devon22
95b302bff6 3.4.13 2026-07-01 22:47:46 +08:00
Devon22
fc220c5218 3.4.12 2026-06-30 23:17:44 +08:00
25 changed files with 965 additions and 321 deletions

View file

@ -1,9 +1,9 @@
{
"id": "gridexplorer",
"name": "GridExplorer",
"version": "3.4.11",
"version": "3.4.19",
"minAppVersion": "1.8.7",
"description": "Browse note files in a grid view.",
"description": "Browse and organize note files visually in a flexible grid layout.",
"author": "Devon22",
"authorUrl": "https://github.com/Devon22",
"isDesktopOnly": false

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "gridexplorer",
"version": "3.4.11",
"version": "3.4.19",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gridexplorer",
"version": "3.4.11",
"version": "3.4.19",
"license": "MIT",
"dependencies": {
"jszip": "^3.10.1"

View file

@ -1,7 +1,7 @@
{
"name": "gridexplorer",
"version": "3.4.11",
"description": "Browse note files in a grid view.",
"version": "3.4.19",
"description": "Browse and organize note files visually in a flexible grid layout.",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",

View file

@ -1,4 +1,4 @@
import { TFile, TFolder, WorkspaceLeaf, Menu, setIcon, Platform, normalizePath, ItemView, EventRef, FuzzySuggestModal, parseLinktext, ViewStateResult } from 'obsidian';
import { TFile, TFolder, WorkspaceLeaf, Menu, setIcon, Platform, normalizePath, ItemView, EventRef, FuzzySuggestModal, parseLinktext, ViewStateResult, Notice } from 'obsidian';
import GridExplorerPlugin from './main';
import { GridView } from './GridView';
import { isFolderIgnored, isImageFile, isVideoFile, isAudioFile, isMediaFile } from './utils/fileUtils';
@ -2225,11 +2225,11 @@ export class ExplorerView extends ItemView {
let handled = false;
for (const line of lines) {
let resolvedFile: TFile | null = null;
try {
let text = line;
if (text.startsWith('!')) text = text.substring(1);
let resolvedFile: TFile | null = null;
if (text.startsWith('[[') && text.endsWith(']]')) {
const inner = text.slice(2, -2);
const parsed = parseLinktext(inner);
@ -2253,6 +2253,7 @@ export class ExplorerView extends ItemView {
handled = true;
}
} catch (error) {
new Notice(`${t('failed_to_move_file')}: ${resolvedFile ? resolvedFile.name : line}`);
console.error('An error occurred while moving one of the files (ExplorerView):', error);
// 繼續處理其他檔案
}

View file

@ -182,12 +182,17 @@ export class FileWatcher {
// 監聽當前開啟的檔案變更,讀取反向連結
this.plugin.registerEvent(
this.app.workspace.on('file-open', (file) => {
if (file instanceof TFile && this.gridView.searchQuery === '') {
const sourceMode = this.gridView.sourceMode;
if (this.gridView.searchQuery !== '') return;
const sourceMode = this.gridView.sourceMode;
if (file instanceof TFile) {
// 處理反向連結和出向連結
if (sourceMode === 'backlinks' || sourceMode === 'outgoinglinks') {
this.scheduleRender();
// 若分頁已釘選,維持鎖定的目標檔案,不跟著切換(與內建反向連結行為一致)
if (!this.gridView.isPinned() && this.gridView.sourcePath !== file.path) {
this.gridView.sourcePath = file.path;
this.scheduleRender();
}
return;
}
@ -202,16 +207,93 @@ export class FileWatcher {
}
}
}
} else if (file === null) {
// 當 file 為 null代表切換到非檔案檢視交由 active-leaf-change 處理
}
})
);
// 監聽 active-leaf-change 來處理切回 GridView 或其他檢視的狀況
this.plugin.registerEvent(
this.app.workspace.on('active-leaf-change', (leaf) => {
if (!leaf || !leaf.view) return;
const view = leaf.view;
// 忽略自己被設為活動的事件,避免自我循環與自我清空
if (view === this.gridView) return;
// 忽略側邊欄的活動分頁變更,避免切換側邊欄頁籤或點擊側邊欄時,反向連結被清空
const isSidebarLeaf = leaf.getRoot() === this.app.workspace.leftSplit ||
leaf.getRoot() === this.app.workspace.rightSplit;
if (isSidebarLeaf) return;
if (this.gridView.searchQuery !== '') return;
const sourceMode = this.gridView.sourceMode;
if (sourceMode === 'backlinks' || sourceMode === 'outgoinglinks') {
if (this.gridView.isPinned()) return;
let newSourcePath = '';
if (view.getViewType() === 'grid-view') {
const activeGridView = view as unknown as Record<string, unknown>;
const previewedFile = activeGridView.previewedFile;
if (activeGridView.isShowingNote === true && previewedFile instanceof TFile) {
newSourcePath = previewedFile.path;
}
} else if ('file' in view && view.file instanceof TFile) {
newSourcePath = view.file.path;
}
// 如果 sourcePath 有改變,就更新並重新渲染
if (this.gridView.sourcePath !== newSourcePath) {
this.gridView.sourcePath = newSourcePath;
this.scheduleRender();
}
}
})
);
// 監聽其他 GridView 在網格內預覽筆記的事件GridPreviewManager.showNoteInGrid/hideNoteInGrid
// 讓側邊欄的反向連結/外部連結模式能跟著預覽的筆記更新,行為與內建反向連結面板一致
this.plugin.registerEvent(
(this.app.workspace as unknown as {
on(name: 'ge-preview-file-change', callback: (file: TFile | null, sourceView: unknown) => void): import('obsidian').EventRef
}).on('ge-preview-file-change', (file, sourceView) => {
// 忽略自己觸發的事件,避免自我循環
if (sourceView === this.gridView) return;
if (this.gridView.searchQuery !== '') return;
const sourceMode = this.gridView.sourceMode;
if (sourceMode !== 'backlinks' && sourceMode !== 'outgoinglinks') return;
// 若分頁已釘選,維持鎖定的目標檔案,不跟著切換
if (this.gridView.isPinned()) return;
if (file instanceof TFile) {
this.gridView.sourcePath = file.path;
this.scheduleRender();
} else if (file === null) {
this.gridView.sourcePath = '';
this.scheduleRender();
}
})
);
}
public renderPending = false;
public performPendingRender() {
if (this.renderPending) {
this.renderPending = false;
this.scheduleRender();
}
}
// 以 200ms 去抖動的方式排程 render避免短時間內大量重繪
private scheduleRender = (delay: number = 200): void => {
// 若分頁被釘選或正在顯示筆記,暫停重新渲染
// 若分頁被釘選或正在顯示筆記/ZIP,暫停重新渲染,但標記為待更新
if ((this.gridView.isPinned() && this.gridView.sourceMode === 'backlinks') ||
this.gridView.isShowingNote) {
this.gridView.isShowingNote || this.gridView.isShowingZip) {
this.renderPending = true;
return;
}
if(this.gridView.sourceMode === 'recent-files' && this.gridView.containerEl.offsetParent === null) {

View file

@ -1,4 +1,4 @@
import { TFile, Platform, setIcon, setTooltip, MarkdownRenderer } from 'obsidian';
import { TFile, Platform, setIcon, setTooltip, MarkdownRenderer, Component, Menu, getFrontMatterInfo, parseYaml } from 'obsidian';
import JSZip from 'jszip';
import { GridView } from './GridView';
import { MediaModal, VirtualMediaFile } from './modal/mediaModal';
@ -8,6 +8,10 @@ interface NoteViewContainerWithKeydownHandler extends HTMLElement {
keydownHandler?: (e: KeyboardEvent) => void;
}
interface MenuItemWithWarning {
setWarning(warning: boolean): this;
}
interface AppWithInternalPlugins {
internalPlugins?: {
plugins: {
@ -27,6 +31,18 @@ export class GridPreviewManager {
private view: GridView;
private previewHistory: TFile[] = [];
private previewHistoryIndex: number = -1;
private previewComponent: Component | null = null;
private preparePreviewComponent() {
if (this.previewComponent) {
this.previewComponent.unload();
this.view.removeChild(this.previewComponent);
this.previewComponent = null;
}
this.previewComponent = new Component();
this.view.addChild(this.previewComponent);
this.previewComponent.load();
}
constructor(view: GridView) {
this.view = view;
@ -88,6 +104,7 @@ export class GridPreviewManager {
let initialScrollTop = 0;
let isAtTop = false;
let isAtBottom = false;
let isFromEdge = false;
const handleTouchStart = (e: TouchEvent) => {
if (e.touches.length > 1) return;
@ -109,11 +126,28 @@ export class GridPreviewManager {
isDragging = false;
isPullingUp = false;
isHorizontalDragging = false;
const edgeThreshold = 24;
isFromEdge = startX < edgeThreshold || startX > window.innerWidth - edgeThreshold;
// Obsidian 自身的 swipe 手勢會在方向判斷完成前就介入,
// 所以必須在 touchstart 一開始就 stopPropagation
// 僅保留螢幕邊緣 24px 讓系統返回手勢通過
if (!isFromEdge) {
e.stopPropagation();
}
};
const handleTouchMove = (e: TouchEvent) => {
if (e.touches.length > 1) return;
// 理由同 handleTouchStart必須盡早攔截以避免 Obsidian 手勢介入
if (!isFromEdge) {
e.stopPropagation();
} else {
return;
}
currentY = e.touches[0].clientY;
currentX = e.touches[0].clientX;
const deltaY = currentY - startY;
@ -152,7 +186,6 @@ export class GridPreviewManager {
// 只有在進入我們自己定義的拖曳狀態時,才攔截事件與阻止預設行為
if (isHorizontalDragging || isDragging) {
e.stopPropagation();
if (e.cancelable) {
e.preventDefault();
}
@ -180,7 +213,7 @@ export class GridPreviewManager {
};
const handleTouchEnd = (e: TouchEvent) => {
if (isHorizontalDragging || isDragging) {
if (!isFromEdge) {
e.stopPropagation();
}
@ -287,6 +320,11 @@ export class GridPreviewManager {
this.view.noteViewContainer = this.view.containerEl.createDiv('ge-note-view-container');
const noteViewContainer = this.view.noteViewContainer;
// 立即設定狀態,確保在非同步渲染期間也能正常響應關閉或跳轉
this.view.isShowingNote = true;
this.view.previewedFile = file;
this.view.app.workspace.trigger('ge-preview-file-change', file, this.view);
// 頂部列 (左右區塊)
const topBar = noteViewContainer.createDiv('ge-note-top-bar');
const leftBar = topBar.createDiv('ge-note-top-left');
@ -323,8 +361,6 @@ export class GridPreviewManager {
setTooltip(noteTitle, file.basename);
}
const rightBar = topBar.createDiv('ge-note-top-right');
// 編輯按鈕
@ -334,11 +370,90 @@ export class GridPreviewManager {
void this.view.getLeafByMode(file).openFile(file);
});
// Metadata 切換按鈕
const infoButton = rightBar.createEl('button', { cls: 'clickable-icon ge-note-info-button' });
setIcon(infoButton, 'info');
// 更多選項按鈕
const moreOptionsButton = rightBar.createEl('button', { cls: 'clickable-icon ge-note-more-options-button' });
setIcon(moreOptionsButton, 'more-vertical');
if (Platform.isDesktop) {
setTooltip(moreOptionsButton, t('more_options'));
}
moreOptionsButton.addEventListener('click', (e) => {
const menu = new Menu();
this.view.app.workspace.trigger('file-menu', menu, file, 'file-explorer');
menu.addSeparator();
menu.addItem((item) => {
item
.setTitle(t('open_in_new_tab'))
.setIcon("external-link")
.setSection?.("open")
.onClick(() => {
void this.view.app.workspace.getLeaf(true).openFile(file);
});
});
if (Platform.isMobile) {
menu.addItem((item) => {
item
.setTitle(t('add_to_stash'))
.setIcon("archive")
.onClick(() => {
(this.view as unknown as { addFilesToStash(files: TFile[]): void }).addFilesToStash([file]);
});
});
}
menu.addItem((item) => {
item
.setTitle(t("make_a_copy"))
.setIcon("copy")
.setSection?.("action")
.onClick(async () => {
const parentPath = file.parent ? file.parent.path : "";
let copyPath = "";
if (parentPath && parentPath !== "/") {
copyPath = `${parentPath}/${file.basename} 1.${file.extension}`;
} else {
copyPath = `${file.basename} 1.${file.extension}`;
}
let counter = 1;
while (this.view.app.vault.getAbstractFileByPath(copyPath)) {
counter++;
if (parentPath && parentPath !== "/") {
copyPath = `${parentPath}/${file.basename} ${counter}.${file.extension}`;
} else {
copyPath = `${file.basename} ${counter}.${file.extension}`;
}
}
try {
await this.view.app.vault.copy(file, copyPath);
} catch (err) {
console.error("Failed to copy file:", err);
}
});
});
menu.addItem((item) => {
(item as unknown as MenuItemWithWarning).setWarning(true);
item
.setTitle(t('delete_note'))
.setIcon('trash')
.onClick(async () => {
await this.view.app.fileManager.trashFile(file);
this.hideNoteInGrid();
});
});
menu.showAtMouseEvent(e);
});
// 關閉按鈕
const closeButton = rightBar.createEl('button', { cls: 'clickable-icon ge-note-close-button' });
setIcon(closeButton, 'x');
@ -363,6 +478,8 @@ export class GridPreviewManager {
}
// 在移動端添加滾動監聽,根據滾動方向控制導航欄顯示/隱藏
this.preparePreviewComponent();
if (Platform.isPhone) {
let lastScrollTop = 0;
let accumulateScroll = 0;
@ -400,7 +517,7 @@ export class GridPreviewManager {
lastScrollTop = currentScrollTop;
};
scrollContainer.addEventListener('scroll', handleScroll);
this.previewComponent!.registerDomEvent(scrollContainer, 'scroll', handleScroll);
// 監聽分頁切換事件,當離開當前視圖時恢復導航欄
const handleActiveLeafChange = () => {
@ -418,14 +535,9 @@ export class GridPreviewManager {
};
// 註冊事件監聽器
this.view.registerEvent(
this.previewComponent!.registerEvent(
this.view.app.workspace.on('active-leaf-change', handleActiveLeafChange)
);
// 儲存滾動事件清理函數
this.view.eventCleanupFunctions.push(() => {
scrollContainer.removeEventListener('scroll', handleScroll);
});
}
// 取得 Metadata (Frontmatter)
@ -433,131 +545,7 @@ export class GridPreviewManager {
const frontmatter = fileCache?.frontmatter;
if (frontmatter) {
// 檢查是否除了 position 以外還有其他屬性
const keys = Object.keys(frontmatter).filter(k => k !== 'position');
if (keys.length > 0) {
const metadataContainer = noteContent.createDiv('ge-note-metadata-container');
// 綁定切換事件
infoButton.addEventListener('click', () => {
metadataContainer.classList.toggle('is-visible');
scrollContainer.scrollTo(0, 0);
});
const metadataContent = metadataContainer.createDiv('ge-note-metadata-content');
for (const key of keys) {
const item = metadataContent.createDiv('ge-note-metadata-item');
item.createSpan({ cls: 'ge-note-metadata-key', text: `${key}: ` });
const value: unknown = frontmatter[key] as unknown;
const valueSpan = item.createSpan({ cls: 'ge-note-metadata-value' });
const values = Array.isArray(value) ? value : [value];
values.forEach((val, index) => {
const valStr = String(val);
// 處理內部連結 [[link]] 或 [[link|alias]]
const wikilinkRegex = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;
// 處理 URL
const urlRegex = /(https?:\/\/[^\s]+)/g;
// 處理 Tag
const tagRegex = /#([^\s#]+)/g;
if (wikilinkRegex.test(valStr)) {
wikilinkRegex.lastIndex = 0;
let lastIndex = 0;
let match;
while ((match = wikilinkRegex.exec(valStr)) !== null) {
// 插入匹配前的文字
if (match.index > lastIndex) {
valueSpan.createSpan({ text: valStr.substring(lastIndex, match.index) });
}
const linkPath = match[1];
const linkAlias = match[2] || linkPath;
const linkEl = valueSpan.createEl('a', {
cls: 'internal-link',
text: linkAlias,
attr: { 'data-href': linkPath }
});
linkEl.addEventListener('click', (e) => {
e.preventDefault();
const linkedFile = this.view.app.metadataCache.getFirstLinkpathDest(linkPath, file.path);
if (linkedFile) {
if (e.ctrlKey || e.metaKey) {
void this.view.getLeafByMode(linkedFile).openFile(linkedFile);
} else {
void this.showNoteInGrid(linkedFile);
}
}
});
lastIndex = wikilinkRegex.lastIndex;
}
if (lastIndex < valStr.length) {
valueSpan.createSpan({ text: valStr.substring(lastIndex) });
}
} else if (urlRegex.test(valStr)) {
urlRegex.lastIndex = 0;
let lastIndex = 0;
let match;
while ((match = urlRegex.exec(valStr)) !== null) {
if (match.index > lastIndex) {
valueSpan.createSpan({ text: valStr.substring(lastIndex, match.index) });
}
const url = match[1];
valueSpan.createEl('a', {
cls: 'external-link',
text: url,
attr: { 'href': url, 'target': '_blank', 'rel': 'noopener' }
});
lastIndex = urlRegex.lastIndex;
}
if (lastIndex < valStr.length) {
valueSpan.createSpan({ text: valStr.substring(lastIndex) });
}
} else if (key.toLowerCase() === 'tags' || key.toLowerCase() === 'tag' || tagRegex.test(valStr)) {
if ((key.toLowerCase() === 'tags' || key.toLowerCase() === 'tag') && !valStr.startsWith('#')) {
const tagEl = valueSpan.createEl('a', {
cls: 'tag',
text: '#' + valStr,
attr: { 'href': '#' + valStr }
});
tagEl.addEventListener('click', (e) => {
e.preventDefault();
(this.view.app as AppWithInternalPlugins).internalPlugins?.getPluginById?.('global-search')?.instance?.openGlobalSearch?.('tag:#' + valStr);
});
} else {
tagRegex.lastIndex = 0;
let lastIndex = 0;
let match;
while ((match = tagRegex.exec(valStr)) !== null) {
if (match.index > lastIndex) {
valueSpan.createSpan({ text: valStr.substring(lastIndex, match.index) });
}
const tagName = match[1];
const tagEl = valueSpan.createEl('a', {
cls: 'tag',
text: '#' + tagName,
attr: { 'href': '#' + tagName }
});
tagEl.addEventListener('click', (e) => {
e.preventDefault();
(this.view.app as AppWithInternalPlugins).internalPlugins?.getPluginById?.('global-search')?.instance?.openGlobalSearch?.('tag:#' + tagName);
});
lastIndex = tagRegex.lastIndex;
}
if (lastIndex < valStr.length) {
valueSpan.createSpan({ text: valStr.substring(lastIndex) });
}
}
} else {
valueSpan.createSpan({ text: valStr });
}
if (index < values.length - 1) {
const isTag = key.toLowerCase() === 'tags' || key.toLowerCase() === 'tag';
valueSpan.createSpan({ text: isTag ? ' ' : ', ' });
}
});
}
}
this.renderMetadata(frontmatter, noteContent, infoButton, scrollContainer, file.path);
}
// 創建筆記內容區域
@ -578,6 +566,7 @@ export class GridPreviewManager {
try {
// 讀取筆記內容
const content = await this.view.app.vault.read(file);
if (!this.view.isShowingNote || this.view.previewedFile !== file) return;
// 使用 Obsidian 的 MarkdownRenderer 渲染內容
await MarkdownRenderer.render(
@ -585,8 +574,9 @@ export class GridPreviewManager {
content,
noteContentArea,
file.path,
this.view
this.previewComponent!
);
if (!this.view.isShowingNote || this.view.previewedFile !== file) return;
// 加上自訂屬性 data-source-path
noteContentArea
@ -617,7 +607,7 @@ export class GridPreviewManager {
};
// 使用 registerDomEvent 註冊事件
this.view.registerDomEvent(noteContentArea, 'click', handleLinkClick);
this.previewComponent!.registerDomEvent(noteContentArea, 'click', handleLinkClick);
} catch (error) {
noteContentArea.textContent = '無法載入筆記內容';
console.error('Error loading note content:', error);
@ -625,13 +615,10 @@ export class GridPreviewManager {
// 渲染反向連結與出站連結區塊
await this.renderLinksSection(file, noteContent);
if (!this.view.isShowingNote || this.view.previewedFile !== file) return;
// 註冊行動裝置滑動手勢
this.registerPreviewTouchEvents(noteViewContainer, scrollContainer, () => this.hideNoteInGrid());
// 設定狀態
this.view.isShowingNote = true;
}
// 隱藏筆記顯示
@ -649,12 +636,28 @@ export class GridPreviewManager {
}
}
if (this.previewComponent) {
this.previewComponent.unload();
this.view.removeChild(this.previewComponent);
this.previewComponent = null;
}
if (this.view.noteViewContainer) {
// 中斷所有正在下載的圖片,釋放瀏覽器連線池給下一次載入
this.view.noteViewContainer
.querySelectorAll<HTMLImageElement>('img')
.forEach((img) => { img.removeAttribute('src'); });
this.view.noteViewContainer.remove();
this.view.noteViewContainer = null;
}
this.view.isShowingNote = false;
this.view.previewedFile = null;
this.view.app.workspace.trigger('ge-preview-file-change', null, this.view);
if (this.view.fileWatcher) {
this.view.fileWatcher.performPendingRender();
}
if (clearHistory) {
this.previewHistory = [];
@ -731,6 +734,11 @@ export class GridPreviewManager {
const rightBar = topBar.createDiv('ge-zip-top-right');
// 建立 info 按鈕 (先建立,預設隱藏,待 zip 載入完後若有同名 md 則顯示)
const infoButton = rightBar.createEl('button', { cls: 'clickable-icon ge-note-info-button' });
setIcon(infoButton, 'info');
infoButton.setCssProps({ display: 'none' });
// 開啟按鈕 (在分頁中開啟 ZIP 檔案)
const openButton = rightBar.createEl('button', { cls: 'clickable-icon ge-zip-open-button' });
setIcon(openButton, 'folder-archive');
@ -745,10 +753,6 @@ export class GridPreviewManager {
// 關閉按鈕
const closeButton = rightBar.createEl('button', { cls: 'clickable-icon ge-zip-close-button' });
setIcon(closeButton, 'x');
closeButton.setAttribute('aria-label', t('zip_close_view'));
if (Platform.isDesktop) {
setTooltip(closeButton, t('zip_close_view'));
}
closeButton.addEventListener('click', () => {
this.hideZipInGrid();
});
@ -770,6 +774,8 @@ export class GridPreviewManager {
}
const zipGridEl = zipContent.createDiv('zip-viewer-grid');
this.preparePreviewComponent();
// 在移動端添加滾動監聽,控制導航欄
if (Platform.isPhone) {
let lastScrollTop = 0;
@ -805,7 +811,7 @@ export class GridPreviewManager {
lastScrollTop = currentScrollTop;
};
scrollContainer.addEventListener('scroll', handleScroll);
this.previewComponent!.registerDomEvent(scrollContainer, 'scroll', handleScroll);
const handleActiveLeafChange = () => {
const activeView = this.view.app.workspace.getActiveViewOfType(GridView);
@ -820,13 +826,9 @@ export class GridPreviewManager {
}
};
this.view.registerEvent(
this.previewComponent!.registerEvent(
this.view.app.workspace.on('active-leaf-change', handleActiveLeafChange)
);
this.view.eventCleanupFunctions.push(() => {
scrollContainer.removeEventListener('scroll', handleScroll);
});
}
// 載入與解析 ZIP 檔案
@ -837,6 +839,39 @@ export class GridPreviewManager {
const zip = await JSZip.loadAsync(arrayBuffer);
this.view.activeZip = zip;
// 檢查 zip 內是否有同名 md 檔案
const zipMdFilename = Object.keys(zip.files).find(name => {
const lower = name.toLowerCase();
const basename = file.basename.toLowerCase();
return !zip.files[name].dir &&
(lower === `${basename}.md` || lower.endsWith(`/${basename}.md`));
});
if (zipMdFilename) {
try {
const mdZipFile = zip.files[zipMdFilename];
const mdContent = await mdZipFile.async("string");
// 取得 Frontmatter 資訊並解析
const frontMatterInfo = getFrontMatterInfo(mdContent);
if (frontMatterInfo && frontMatterInfo.exists) {
const frontmatter = parseYaml(frontMatterInfo.frontmatter) as Record<string, unknown>;
if (frontmatter) {
this.renderMetadata(frontmatter, scrollContainer, infoButton, scrollContainer, file.path, true, isInSidebar);
// 因為 metadataContainer 是在非同步加載後才建立,需將它移動至 scrollContainer 的最上方 (即 zipContent 之前)
const metadataEl = scrollContainer.querySelector('.ge-note-metadata-container');
if (metadataEl && scrollContainer.firstChild && metadataEl !== scrollContainer.firstChild) {
scrollContainer.insertBefore(metadataEl, scrollContainer.firstChild);
}
// 顯示切換按鈕
infoButton.setCssProps({ display: '' });
}
}
} catch (err) {
console.error("Failed to read/render companion md inside ZIP:", err);
}
}
const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico'];
this.view.zipImageFiles = Object.keys(zip.files)
.filter(filename => {
@ -1124,6 +1159,12 @@ export class GridPreviewManager {
hideZipInGrid(clearHistory = true) {
if (!this.view.isShowingZip) return;
if (this.previewComponent) {
this.previewComponent.unload();
this.view.removeChild(this.previewComponent);
this.previewComponent = null;
}
// 顯示移動端導航欄 (僅在行動裝置上)
if (Platform.isPhone) {
const mobileNavbar = activeDocument.querySelector('.mobile-navbar') as HTMLElement;
@ -1160,6 +1201,10 @@ export class GridPreviewManager {
this.view.isShowingZip = false;
if (this.view.fileWatcher) {
this.view.fileWatcher.performPendingRender();
}
if (clearHistory) {
this.previewHistory = [];
this.previewHistoryIndex = -1;
@ -1250,6 +1295,8 @@ export class GridPreviewManager {
text: file.basename
});
this.preparePreviewComponent();
// 3. 讀取與渲染
const content = await this.view.app.vault.read(file);
await MarkdownRenderer.render(
@ -1257,7 +1304,7 @@ export class GridPreviewManager {
content,
noteContentArea as HTMLElement,
file.path,
this.view
this.previewComponent!
);
// 4. 加上圖片的 data-source-path
@ -1278,6 +1325,152 @@ export class GridPreviewManager {
console.error('Error updating current preview note:', error);
}
}
private renderMetadata(
frontmatter: Record<string, unknown>,
parentEl: HTMLElement,
infoButton: HTMLButtonElement,
scrollContainer: HTMLElement,
filePath: string,
isZip = false,
isInSidebar = false
) {
const keys = Object.keys(frontmatter).filter(k => k !== 'position');
if (keys.length > 0) {
const metadataContainer = parentEl.createDiv('ge-note-metadata-container');
// 如果是在 zip 中渲染,需要加上 margin 外間距使其跟 showNoteInGrid 左右對齊
if (isZip) {
metadataContainer.setCssProps({
margin: isInSidebar ? '15px 15px 0 15px' : '20px 20px 0 20px'
});
}
// 綁定切換事件
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 = frontmatter[key];
const valueSpan = item.createSpan({ cls: 'ge-note-metadata-value' });
const values = Array.isArray(value) ? value : [value];
values.forEach((val, index) => {
let valStr = '';
if (val instanceof Date) {
const yyyy = val.getFullYear();
const mm = String(val.getMonth() + 1).padStart(2, '0');
const dd = String(val.getDate()).padStart(2, '0');
valStr = `${yyyy}-${mm}-${dd}`;
} else {
valStr = String(val);
}
const wikilinkRegex = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;
const urlRegex = /(https?:\/\/[^\s]+)/g;
const tagRegex = /#([^\s#]+)/g;
if (wikilinkRegex.test(valStr)) {
wikilinkRegex.lastIndex = 0;
let lastIndex = 0;
let match;
while ((match = wikilinkRegex.exec(valStr)) !== null) {
if (match.index > lastIndex) {
valueSpan.createSpan({ text: valStr.substring(lastIndex, match.index) });
}
const linkPath = match[1];
const linkAlias = match[2] || linkPath;
const linkEl = valueSpan.createEl('a', {
cls: 'internal-link',
text: linkAlias,
attr: { 'data-href': linkPath }
});
linkEl.addEventListener('click', (e) => {
e.preventDefault();
const linkedFile = this.view.app.metadataCache.getFirstLinkpathDest(linkPath, filePath);
if (linkedFile) {
if (e.ctrlKey || e.metaKey) {
void this.view.getLeafByMode(linkedFile).openFile(linkedFile);
} else {
void this.showNoteInGrid(linkedFile);
}
}
});
lastIndex = wikilinkRegex.lastIndex;
}
if (lastIndex < valStr.length) {
valueSpan.createSpan({ text: valStr.substring(lastIndex) });
}
} else if (urlRegex.test(valStr)) {
urlRegex.lastIndex = 0;
let lastIndex = 0;
let match;
while ((match = urlRegex.exec(valStr)) !== null) {
if (match.index > lastIndex) {
valueSpan.createSpan({ text: valStr.substring(lastIndex, match.index) });
}
const url = match[1];
valueSpan.createEl('a', {
cls: 'external-link',
text: url,
attr: { 'href': url, 'target': '_blank', 'rel': 'noopener' }
});
lastIndex = urlRegex.lastIndex;
}
if (lastIndex < valStr.length) {
valueSpan.createSpan({ text: valStr.substring(lastIndex) });
}
} else if (key.toLowerCase() === 'tags' || key.toLowerCase() === 'tag' || tagRegex.test(valStr)) {
if ((key.toLowerCase() === 'tags' || key.toLowerCase() === 'tag') && !valStr.startsWith('#')) {
const tagEl = valueSpan.createEl('a', {
cls: 'tag',
text: '#' + valStr,
attr: { 'href': '#' + valStr }
});
tagEl.addEventListener('click', (e) => {
e.preventDefault();
(this.view.app as AppWithInternalPlugins).internalPlugins?.getPluginById?.('global-search')?.instance?.openGlobalSearch?.('tag:#' + valStr);
});
} else {
tagRegex.lastIndex = 0;
let lastIndex = 0;
let match;
while ((match = tagRegex.exec(valStr)) !== null) {
if (match.index > lastIndex) {
valueSpan.createSpan({ text: valStr.substring(lastIndex, match.index) });
}
const tagName = match[1];
const tagEl = valueSpan.createEl('a', {
cls: 'tag',
text: '#' + tagName,
attr: { 'href': '#' + tagName }
});
tagEl.addEventListener('click', (e) => {
e.preventDefault();
(this.view.app as AppWithInternalPlugins).internalPlugins?.getPluginById?.('global-search')?.instance?.openGlobalSearch?.('tag:#' + tagName);
});
lastIndex = tagRegex.lastIndex;
}
if (lastIndex < valStr.length) {
valueSpan.createSpan({ text: valStr.substring(lastIndex) });
}
}
} else {
valueSpan.createSpan({ text: valStr });
}
if (index < values.length - 1) {
const isTag = key.toLowerCase() === 'tags' || key.toLowerCase() === 'tag';
valueSpan.createSpan({ text: isTag ? ' ' : ', ' });
}
});
}
}
}
}

View file

@ -110,6 +110,8 @@ interface GridViewStateData {
showIgnoredItems?: boolean;
baseCardLayout?: 'horizontal' | 'vertical';
cardLayout?: 'horizontal' | 'vertical';
baseShowNoteInGrid?: boolean;
showNoteInGridState?: boolean;
hideHeaderElements?: boolean;
showFileNameFilter?: boolean;
baseShowDateDividers?: boolean;
@ -188,9 +190,12 @@ export class GridView extends ItemView {
customOptionIndex: number = -1; // 自訂模式選項索引
baseCardLayout: 'horizontal' | 'vertical' = 'horizontal'; // 使用者在設定或 UI 中選擇的基礎卡片樣式(不受資料夾臨時覆蓋影響)
cardLayout: 'horizontal' | 'vertical' = 'horizontal'; // 目前實際使用的卡片樣式(可能被資料夾 metadata 臨時覆蓋)
baseShowNoteInGrid: boolean = false; // 基礎在網格中顯示筆記(不受資料夾臨時覆蓋影響)
showNoteInGridState: boolean = false; // 目前實際使用的在網格中顯示筆記狀態(可能被資料夾 metadata 臨時覆蓋)
renderToken: number = 0; // 用於取消尚未完成之批次排程的遞增令牌
isShowingNote: boolean = false; // 是否正在顯示筆記
noteViewContainer: HTMLElement | null = null; // 筆記檢視容器
previewedFile: TFile | null = null; // 目前在網格內預覽顯示的筆記檔案(供其他 GridView 的反向連結/外部連結模式跟蹤)
isShowingZip: boolean = false; // 是否正在顯示 ZIP 預覽
zipViewContainer: HTMLElement | null = null; // ZIP 檢視容器
zipThumbnailUrls: Map<number, string> = new Map(); // 暫存縮圖 Blob URL
@ -211,6 +216,8 @@ export class GridView extends ItemView {
this.sortType = this.baseSortType;
this.baseCardLayout = this.plugin.settings.cardLayout;
this.cardLayout = this.baseCardLayout;
this.baseShowNoteInGrid = this.plugin.settings.showNoteInGrid;
this.showNoteInGridState = this.baseShowNoteInGrid;
this.baseMinMode = false;
this.minMode = this.baseMinMode;
this.baseShowDateDividers = this.plugin.settings.dateDividerMode !== 'none';
@ -635,6 +642,7 @@ export class GridView extends ItemView {
let folderSort: string | undefined;
let tempMinMode: boolean = this.baseMinMode;
let tempShowDateDividers: boolean = this.baseShowDateDividers;
let tempShowNoteInGrid: boolean = this.baseShowNoteInGrid;
if (mdFile instanceof TFile) {
const metadata = this.app.metadataCache.getFileCache(mdFile)?.frontmatter;
@ -652,10 +660,16 @@ export class GridView extends ItemView {
} else if (metadata?.showDateDividers === false || metadata?.showDateDividers === 'false') {
tempShowDateDividers = false;
}
if (metadata?.showNoteInGrid === true || metadata?.showNoteInGrid === 'true') {
tempShowNoteInGrid = true;
} else if (metadata?.showNoteInGrid === false || metadata?.showNoteInGrid === 'false') {
tempShowNoteInGrid = false;
}
}
this.cardLayout = tempLayout;
this.minMode = tempMinMode;
this.showDateDividers = tempShowDateDividers;
this.showNoteInGridState = tempShowNoteInGrid;
// 根據資料夾 frontmatter 的 sort 覆蓋實際排序,否則使用 baseSortType
this.sortType = folderSort && typeof folderSort === 'string' && folderSort.trim() !== ''
@ -666,6 +680,7 @@ export class GridView extends ItemView {
this.cardLayout = this.baseCardLayout; // 回復基礎卡片排列
this.minMode = this.baseMinMode; // 回復基礎最小化模式
this.showDateDividers = this.baseShowDateDividers; // 回復基礎日期分隔器模式
this.showNoteInGridState = this.baseShowNoteInGrid; // 回復基礎在網格中顯示筆記狀態
this.sourcePath = '/'; // 強制設定路徑為根目錄 (創建筆記用)
// 切換到自訂模式時:重設選項索引,並將排序設為 'none'
@ -1071,8 +1086,8 @@ export class GridView extends ItemView {
return;
}
// 如果是反向連結模式,但沒有活動中的檔案
if (this.sourceMode === 'backlinks' && !this.app.workspace.getActiveFile()) {
// 如果是反向連結模式,但沒有有效的目標檔案
if (this.sourceMode === 'backlinks' && !(this.app.vault.getAbstractFileByPath(this.sourcePath) instanceof TFile)) {
const noFilesDiv = container.createDiv('ge-no-files');
noFilesDiv.setText(t('no_backlinks'));
if (this.plugin.statusBarItem) {
@ -1247,15 +1262,19 @@ export class GridView extends ItemView {
// 刪除註解及連結
contentWithoutMediaLinks = contentWithoutMediaLinks
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/!?\[([^\]]*)\]\([^)]+\)|!?\[\[([^\]]+)\]\]/g, (_match: string, p1?: string, p2?: string) => {
const rawLinkText = p1 || p2 || '';
if (!rawLinkText) return '';
.replace(/(?:!?\[([^\]]*)\]\(([^)]+)\))|(?:!?\[\[([^\]]+)\]\])/g, (_match: string, p1?: string, p2?: string, p3?: string) => {
if (p3) {
// WikiLink
const wikiLinkParts = p3.split('|');
const linkTarget = wikiLinkParts[0];
const linkText = wikiLinkParts.length > 1 ? wikiLinkParts.slice(1).join('|') : p3;
const extension = linkTarget.split('.').pop()?.toLowerCase() || '';
return (IMAGE_EXTENSIONS.has(extension) || VIDEO_EXTENSIONS.has(extension)) ? '' : linkText;
}
const wikiLinkParts = p2 ? rawLinkText.split('|') : [];
const linkTarget = p2 ? wikiLinkParts[0] : rawLinkText;
const linkText = p2 && wikiLinkParts.length > 1 ? wikiLinkParts.slice(1).join('|') : rawLinkText;
// 獲取副檔名並檢查是否為圖片或影片
// Standard Markdown link/image
const linkText = p1 || '';
const linkTarget = (p2 || '').split('?')[0];
const extension = linkTarget.split('.').pop()?.toLowerCase() || '';
return (IMAGE_EXTENSIONS.has(extension) || VIDEO_EXTENSIONS.has(extension)) ? '' : linkText;
});
@ -1522,6 +1541,7 @@ export class GridView extends ItemView {
// Markdown 檔案尋找內部圖片
if (imageUrl) {
const img = imageArea.createEl('img');
img.referrerPolicy = 'no-referrer';
img.src = imageUrl;
img.draggable = false;
imageArea.setAttribute('data-loaded', 'true');
@ -1851,7 +1871,7 @@ export class GridView extends ItemView {
}*/
// 滑鼠懸停在項目上時,按 Ctrl 鍵直接顯示筆記或 ZIP 圖片網格
if (Platform.isDesktop && (file.extension === 'md' || file.extension === 'zip') && !this.plugin.settings.showNoteInGrid) {
if (Platform.isDesktop && (file.extension === 'md' || file.extension === 'zip') && !this.showNoteInGridState) {
let triggeredInHover = false;
let isHovering = false;
let isMouseDown = false; // 追蹤滑鼠按下狀態
@ -1977,7 +1997,7 @@ export class GridView extends ItemView {
this.hasKeyboardFocus = true;
event.preventDefault();
return;
} else if (this.plugin.settings.showNoteInGrid) {
} else if (this.showNoteInGridState) {
this.selectItem(index);
this.hasKeyboardFocus = true;
@ -2645,6 +2665,8 @@ export class GridView extends ItemView {
showIgnoredItems: this.showIgnoredItems,
baseCardLayout: this.baseCardLayout,
cardLayout: this.cardLayout,
baseShowNoteInGrid: this.baseShowNoteInGrid,
showNoteInGridState: this.showNoteInGridState,
hideHeaderElements: this.hideHeaderElements,
showFileNameFilter: this.showFileNameFilter,
baseShowDateDividers: this.baseShowDateDividers,
@ -2675,6 +2697,8 @@ export class GridView extends ItemView {
this.showIgnoredItems = state.state.showIgnoredItems ?? false;
this.baseCardLayout = state.state.baseCardLayout ?? 'horizontal';
this.cardLayout = state.state.cardLayout ?? this.baseCardLayout;
this.baseShowNoteInGrid = state.state.baseShowNoteInGrid ?? state.state.showNoteInGridState ?? this.plugin.settings.showNoteInGrid;
this.showNoteInGridState = state.state.showNoteInGridState ?? this.baseShowNoteInGrid;
this.hideHeaderElements = state.state.hideHeaderElements ?? false;
this.showFileNameFilter = state.state.showFileNameFilter ?? this.plugin.settings.showFileNameFilter;
this.baseShowDateDividers = state.state.baseShowDateDividers ?? state.state.showDateDividers ?? (this.plugin.settings.dateDividerMode !== 'none');

View file

@ -1,4 +1,4 @@
import { FileView, WorkspaceLeaf, TFile } from 'obsidian';
import { FileView, WorkspaceLeaf, TFile, getFrontMatterInfo, parseYaml } from 'obsidian';
import JSZip from 'jszip';
import { MediaModal, VirtualMediaFile } from './modal/mediaModal';
@ -124,6 +124,173 @@ export class ZipView extends FileView {
return;
}
// 移除可能殘留的 metadata container
const oldMeta = this.containerEl.querySelector('.ge-note-metadata-container');
if (oldMeta) oldMeta.remove();
// 檢查 zip 內是否有同名 md 檔案
const zipMdFilename = Object.keys(zip.files).find(name => {
const lower = name.toLowerCase();
const basename = file.basename.toLowerCase();
return !zip.files[name].dir &&
(lower === `${basename}.md` || lower.endsWith(`/${basename}.md`));
});
if (zipMdFilename) {
try {
const mdZipFile = zip.files[zipMdFilename];
const mdContent = await mdZipFile.async("string");
const frontMatterInfo = getFrontMatterInfo(mdContent);
if (frontMatterInfo && frontMatterInfo.exists) {
const frontmatter = parseYaml(frontMatterInfo.frontmatter) as Record<string, unknown>;
if (frontmatter) {
const keys = Object.keys(frontmatter).filter(k => k !== 'position');
if (keys.length > 0) {
const gridContainer = this.gridContainer;
if (gridContainer) {
const metadataContainer = gridContainer.createDiv('ge-note-metadata-container');
metadataContainer.addClass('is-visible');
metadataContainer.setCssProps({
margin: '0 0 20px 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 = frontmatter[key];
const valueSpan = item.createSpan({ cls: 'ge-note-metadata-value' });
const values = Array.isArray(value) ? value : [value];
values.forEach((val, idx) => {
let valStr = '';
if (val instanceof Date) {
const yyyy = val.getFullYear();
const mm = String(val.getMonth() + 1).padStart(2, '0');
const dd = String(val.getDate()).padStart(2, '0');
valStr = `${yyyy}-${mm}-${dd}`;
} else {
valStr = String(val);
}
const wikilinkRegex = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;
const urlRegex = /(https?:\/\/[^\s]+)/g;
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.app.workspace.getLeaf(true).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)) {
interface AppWithInternalPlugins {
internalPlugins?: {
getPluginById?: (id: string) => {
instance?: {
openGlobalSearch?: (query: string) => void;
};
} | undefined;
};
}
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 (idx < values.length - 1) {
valueSpan.createSpan({ text: ', ' });
}
});
}
// 將 metadataContainer 置於 gridContainer 頂部 (滾動區最上方,即 gridEl 之前)
if (gridContainer.firstChild && metadataContainer !== gridContainer.firstChild) {
gridContainer.insertBefore(metadataContainer, gridContainer.firstChild);
}
}
}
}
}
} catch (err) {
console.error("Failed to parse companion metadata inside ZIP:", err);
}
}
this.initGrid();
this.currentIndex = 0;

View file

@ -145,7 +145,7 @@ export default class GridExplorerPlugin extends Plugin {
if (activeFile) {
const view = await this.activateView();
if (view instanceof GridView) {
await view.setSource('backlinks');
await view.setSource('backlinks', activeFile.path);
}
} else {
// 如果沒有當前筆記,則打開根目錄
@ -163,7 +163,7 @@ export default class GridExplorerPlugin extends Plugin {
if (activeFile) {
const view = await this.activateView();
if (view instanceof GridView) {
await view.setSource('outgoinglinks');
await view.setSource('outgoinglinks', activeFile.path);
}
} else {
// 如果沒有當前筆記,則打開根目錄
@ -282,7 +282,7 @@ export default class GridExplorerPlugin extends Plugin {
await this.app.workspace.getLeaf().openFile(file);
const view = await this.activateView();
if (view instanceof GridView) {
await view.setSource('backlinks');
await view.setSource('backlinks', file.path);
}
});
});
@ -294,7 +294,7 @@ export default class GridExplorerPlugin extends Plugin {
await this.app.workspace.getLeaf().openFile(file);
const view = await this.activateView();
if (view instanceof GridView) {
await view.setSource('outgoinglinks');
await view.setSource('outgoinglinks', file.path);
}
});
});
@ -398,8 +398,13 @@ export default class GridExplorerPlugin extends Plugin {
// 攔截所有tag點擊事件
this.registerDomEvent(activeDocument, 'click', async (evt: MouseEvent) => {
// 如果未啟用攔截所有tag點擊事件則跳過
if (!this.settings.interceptAllTagClicks) return;
// 判斷是否符合攔截條件
const tagMode = this.settings.interceptAllTagClicks;
if (tagMode === 'disabled') return;
if (tagMode === 'alt' && !evt.altKey) return;
if (tagMode === 'ctrl' && !(evt.ctrlKey || evt.metaKey)) return;
if (tagMode === 'shift' && !evt.shiftKey) return;
// 只處理左鍵
if (evt.button !== 0) return;
@ -462,11 +467,18 @@ export default class GridExplorerPlugin extends Plugin {
// 攔截Breadcrumb導航點擊事件
this.registerDomEvent(activeDocument, 'click', async (evt: MouseEvent) => {
// 如果未啟用攔截Breadcrumb導航點擊事件則跳過
if (!this.settings.interceptBreadcrumbClicks) return;
//如果有按著Ctrl鍵則跳過
if (evt.ctrlKey || evt.metaKey) return;
// 判斷是否符合攔截條件
const crumbMode = this.settings.interceptBreadcrumbClicks;
if (crumbMode === 'disabled') return;
if (crumbMode === 'enabled') {
if (evt.ctrlKey || evt.metaKey) return; // 預設功能,有按 Ctrl 鍵則跳過
} else if (crumbMode === 'alt') {
if (!evt.altKey) return;
} else if (crumbMode === 'ctrl') {
if (!(evt.ctrlKey || evt.metaKey)) return;
} else if (crumbMode === 'shift') {
if (!evt.shiftKey) return;
}
const target = evt.target as HTMLElement;
const breadcrumbEl = target.closest('.view-header-breadcrumb');
@ -535,7 +547,7 @@ export default class GridExplorerPlugin extends Plugin {
};
};
if (appWithPlugins.plugins?.plugins) {
appWithPlugins.plugins.plugins['obsidian-gridexplorer'] = this;
appWithPlugins.plugins.plugins['gridexplorer'] = this;
}
// Override new tab behavior (for useQuickAccessAsNewTabMode setting)
@ -838,6 +850,15 @@ export default class GridExplorerPlugin extends Plugin {
async loadSettings() {
const loadedSettings = await this.loadData() as Partial<GallerySettings> | null;
this.settings = Object.assign({}, DEFAULT_SETTINGS, loadedSettings ?? {});
// 相容舊版 boolean 設定
if (typeof this.settings.interceptAllTagClicks === 'boolean') {
this.settings.interceptAllTagClicks = this.settings.interceptAllTagClicks ? 'enabled' : 'disabled';
}
if (typeof this.settings.interceptBreadcrumbClicks === 'boolean') {
this.settings.interceptBreadcrumbClicks = this.settings.interceptBreadcrumbClicks ? 'enabled' : 'disabled';
}
migrateDataviewCodeToQuery(this.settings);
updateCustomDocumentExtensions(this.settings);
}

View file

@ -179,11 +179,11 @@ export class FolderSelectionModal extends Modal {
backlinksOption.addEventListener('click', () => {
void (async () => {
if (this.activeView) {
await this.activeView.setSource('backlinks');
await this.activeView.setSource('backlinks', activeFile.path);
} else {
const view = await this.plugin.activateView();
if (view instanceof GridView) {
await view.setSource('backlinks');
await view.setSource('backlinks', activeFile.path);
}
}
this.close();
@ -206,11 +206,11 @@ export class FolderSelectionModal extends Modal {
outgoinglinksOption.addEventListener('click', () => {
void (async () => {
if (this.activeView) {
await this.activeView.setSource('outgoinglinks');
await this.activeView.setSource('outgoinglinks', activeFile.path);
} else {
const view = await this.plugin.activateView();
if (view instanceof GridView) {
await view.setSource('outgoinglinks');
await view.setSource('outgoinglinks', activeFile.path);
}
}
this.close();

View file

@ -85,7 +85,7 @@ export class MediaModal extends Modal {
plugins?: Record<string, { activeMediaModal?: unknown }>;
};
};
const plugin = appWithPlugins.plugins?.plugins?.['obsidian-gridexplorer'];
const plugin = appWithPlugins.plugins?.plugins?.['gridexplorer'];
if (plugin) {
plugin.activeMediaModal = this;
}
@ -205,7 +205,7 @@ export class MediaModal extends Modal {
plugins?: Record<string, { activeMediaModal?: unknown }>;
};
};
const plugin = appWithPlugins.plugins?.plugins?.['obsidian-gridexplorer'];
const plugin = appWithPlugins.plugins?.plugins?.['gridexplorer'];
if (plugin && plugin.activeMediaModal === this) {
plugin.activeMediaModal = null;
}

View file

@ -1,4 +1,4 @@
import { TFolder, TFile, normalizePath, Platform, setIcon, setTooltip, Menu, parseLinktext } from 'obsidian';
import { TFolder, TFile, normalizePath, Platform, setIcon, setTooltip, Menu, parseLinktext, Notice } from 'obsidian';
import { GridView } from './GridView';
import { isFolderIgnored } from './utils/fileUtils';
import { extractObsidianPathsFromDT } from './utils/dragUtils';
@ -93,6 +93,7 @@ export async function renderFolder(gridView: GridView, container: HTMLElement) {
await gridView.app.fileManager.renameFile(resolved, newPath);
}
} catch (error) {
new Notice(`${t('failed_to_move_file')}: ${resolved.name}`);
console.error(`An error occurred while moving the file ${p}:`, error);
}
} else {
@ -117,11 +118,11 @@ export async function renderFolder(gridView: GridView, container: HTMLElement) {
.filter((v: string): v is string => v.length > 0);
for (const line of lines) {
let resolvedFile: TFile | null = null;
try {
let text = line;
if (text.startsWith('!')) text = text.substring(1);
let resolvedFile: TFile | null = null;
if (text.startsWith('[[') && text.endsWith(']]')) {
const inner = text.slice(2, -2);
const parsed = parseLinktext(inner);
@ -144,6 +145,7 @@ export async function renderFolder(gridView: GridView, container: HTMLElement) {
}
}
} catch (error) {
new Notice(`${t('failed_to_move_file')}: ${resolvedFile ? resolvedFile.name : line}`);
console.error('An error occurred while moving one of the files (container):', error);
// 繼續處理其他檔案
}
@ -525,6 +527,7 @@ export async function renderFolder(gridView: GridView, container: HTMLElement) {
await gridView.app.fileManager.renameFile(resolved, newPath);
}
} catch (error) {
new Notice(`${t('failed_to_move_file')}: ${resolved.name}`);
console.error(`An error occurred while moving the file ${p}:`, error);
}
} else {
@ -555,37 +558,38 @@ export async function renderFolder(gridView: GridView, container: HTMLElement) {
.map((s: string) => s.trim())
.filter((v: string): v is string => v.length > 0);
for (const line of lines) {
try {
let text = line;
if (text.startsWith('!')) text = text.substring(1); // 去除 '!'
let resolvedFile: TFile | null = null;
if (text.startsWith('[[') && text.endsWith(']]')) {
const inner = text.slice(2, -2);
const parsed = parseLinktext(inner);
const dest = gridView.app.metadataCache.getFirstLinkpathDest(parsed.path, srcPath);
if (dest instanceof TFile) resolvedFile = dest;
} else {
const direct = gridView.app.vault.getAbstractFileByPath(text);
if (direct instanceof TFile) {
resolvedFile = direct;
} else {
const dest = gridView.app.metadataCache.getFirstLinkpathDest(text, srcPath);
if (dest instanceof TFile) resolvedFile = dest;
}
}
try {
let text = line;
if (text.startsWith('!')) text = text.substring(1); // 去除 '!'
if (resolvedFile instanceof TFile) {
const newPath = normalizePath(`${folderPath}/${resolvedFile.name}`);
if (resolvedFile.path !== newPath) {
await gridView.app.fileManager.renameFile(resolvedFile, newPath);
if (text.startsWith('[[') && text.endsWith(']]')) {
const inner = text.slice(2, -2);
const parsed = parseLinktext(inner);
const dest = gridView.app.metadataCache.getFirstLinkpathDest(parsed.path, srcPath);
if (dest instanceof TFile) resolvedFile = dest;
} else {
const direct = gridView.app.vault.getAbstractFileByPath(text);
if (direct instanceof TFile) {
resolvedFile = direct;
} else {
const dest = gridView.app.metadataCache.getFirstLinkpathDest(text, srcPath);
if (dest instanceof TFile) resolvedFile = dest;
}
}
if (resolvedFile instanceof TFile) {
const newPath = normalizePath(`${folderPath}/${resolvedFile.name}`);
if (resolvedFile.path !== newPath) {
await gridView.app.fileManager.renameFile(resolvedFile, newPath);
}
}
} catch (error) {
new Notice(`${t('failed_to_move_file')}: ${resolvedFile ? resolvedFile.name : line}`);
console.error('An error occurred while moving one of the files:', error);
// 繼續處理其他檔案
}
} catch (error) {
console.error('An error occurred while moving one of the files:', error);
// 繼續處理其他檔案
}
}
})();
});
});

View file

@ -529,6 +529,19 @@ export function renderHeaderButton(gridView: GridView) {
void gridView.render();
});
});
// 預設在網格中顯示筆記
moreMenu.addItem((item) => {
item
.setTitle(t('show_note_in_grid'))
.setIcon('book-open')
.setChecked(gridView.showNoteInGridState)
.onClick(() => {
gridView.baseShowNoteInGrid = !gridView.showNoteInGridState;
gridView.showNoteInGridState = gridView.baseShowNoteInGrid;
gridView.app.workspace.requestSaveLayout();
void gridView.render();
});
});
// 顯示忽略的資料夾及檔案選項
moreMenu.addItem((item) => {
item

View file

@ -1,4 +1,4 @@
import { TFolder, TFile, Menu, Platform, setIcon, normalizePath, setTooltip, parseLinktext } from 'obsidian';
import { TFolder, TFile, Menu, Platform, setIcon, normalizePath, setTooltip, parseLinktext, Notice } from 'obsidian';
import { GridView } from './GridView';
import { isFolderIgnored } from './utils/fileUtils';
import { extractObsidianPathsFromDT } from './utils/dragUtils';
@ -312,10 +312,9 @@ export function renderModePath(gridView: GridView) {
// 處理 obsidian:// URI 格式(單檔/多檔)
const obsidianPaths = await extractObsidianPathsFromDT(event.dataTransfer);
if (obsidianPaths.length > 0) {
try {
for (const filePath of obsidianPaths) {
let resolved: TFile | null = null;
for (const filePath of obsidianPaths) {
let resolved: TFile | null = null;
try {
// 1) 直接以路徑查找
const direct = gridView.app.vault.getAbstractFileByPath(filePath);
if (direct instanceof TFile) {
@ -341,9 +340,10 @@ export function renderModePath(gridView: GridView) {
await gridView.app.fileManager.renameFile(resolved, newPath);
}
}
} catch (error) {
new Notice(`${t('failed_to_move_file')}: ${resolved ? resolved.name : filePath}`);
console.error('An error occurred while moving multiple files to folder:', error);
}
} catch (error) {
console.error('An error occurred while moving multiple files to folder:', error);
}
return;
}
@ -359,11 +359,11 @@ export function renderModePath(gridView: GridView) {
.filter((v: string): v is string => v.length > 0);
for (const line of lines) {
let resolvedFile: TFile | null = null;
try {
let text = line;
if (text.startsWith('!')) text = text.substring(1);
let resolvedFile: TFile | null = null;
if (text.startsWith('[[') && text.endsWith(']]')) {
const inner = text.slice(2, -2);
const parsed = parseLinktext(inner);
@ -386,6 +386,7 @@ export function renderModePath(gridView: GridView) {
}
}
} catch (error) {
new Notice(`${t('failed_to_move_file')}: ${resolvedFile ? resolvedFile.name : line}`);
console.error('An error occurred while moving one of the files to folder:', error);
// 繼續處理其他檔案
}
@ -670,18 +671,18 @@ export function renderModePath(gridView: GridView) {
case 'backlinks': {
modeIcon = '🔗';
modeName = t('backlinks_mode');
const activeFile = gridView.app.workspace.getActiveFile();
if (activeFile) {
modeName += `: ${activeFile.basename}`;
const targetFile = gridView.app.vault.getAbstractFileByPath(gridView.sourcePath);
if (targetFile instanceof TFile) {
modeName += `: ${targetFile.basename}`;
}
break;
}
case 'outgoinglinks': {
modeIcon = '🔗';
modeName = t('outgoinglinks_mode');
const currentFile = gridView.app.workspace.getActiveFile();
if (currentFile) {
modeName += `: ${currentFile.basename}`;
const targetFile = gridView.app.vault.getAbstractFileByPath(gridView.sourcePath);
if (targetFile instanceof TFile) {
modeName += `: ${targetFile.basename}`;
}
break;
}

View file

@ -37,6 +37,8 @@ function isCustomMode(value: unknown): value is CustomMode {
);
}
export type InterceptMode = 'enabled' | 'alt' | 'ctrl' | 'shift' | 'disabled';
export interface GallerySettings {
ignoredFolders: string[]; // 要忽略的資料夾路徑
ignoredFolderPatterns: string[]; // 要忽略的資料夾模式
@ -81,8 +83,8 @@ export interface GallerySettings {
dateDividerMode: string; // 日期分隔器模式none, year, month, day
showCodeBlocksInSummary: boolean; // 是否在摘要中顯示程式碼區塊
folderNoteDisplaySettings: string; // 資料夾筆記設定
interceptAllTagClicks: boolean; // 攔截所有tag點擊事件
interceptBreadcrumbClicks: boolean; // 攔截Breadcrumb點擊事件
interceptAllTagClicks: InterceptMode; // 攔截所有tag點擊事件
interceptBreadcrumbClicks: InterceptMode; // 攔截Breadcrumb點擊事件
customModes: CustomMode[]; // 自訂模式
quickAccessCommandPath: string; // Path used by "Open quick access folder" command
quickAccessModeType: string; // View types used by "Open quick access view" command
@ -141,8 +143,8 @@ export const DEFAULT_SETTINGS: GallerySettings = {
dateDividerMode: 'none', // 預設不使用日期分隔器
showCodeBlocksInSummary: false, // 預設不在摘要中顯示程式碼區塊
folderNoteDisplaySettings: 'default', // 預設不處理資料夾筆記
interceptAllTagClicks: true, // 預設攔截所有tag點擊事件
interceptBreadcrumbClicks: true, // 預設攔截Breadcrumb點擊事件
interceptAllTagClicks: 'enabled', // 預設攔截所有tag點擊事件
interceptBreadcrumbClicks: 'enabled', // 預設攔截Breadcrumb點擊事件
customModes: [
{
internalName: 'custom-1750837329297',
@ -881,26 +883,34 @@ export class GridExplorerSettingTab extends PluginSettingTab {
new Setting(sectionEl)
.setName(t('intercept_all_tag_clicks'))
.setDesc(t('intercept_all_tag_clicks_desc'))
.addToggle(toggle => {
toggle
.setValue(this.plugin.settings.interceptAllTagClicks)
.onChange(async (value) => {
this.plugin.settings.interceptAllTagClicks = value;
await this.plugin.saveSettings();
});
.addDropdown(drop => {
drop.addOption('enabled', t('intercept_mode_enabled'));
drop.addOption('alt', t('intercept_mode_alt'));
drop.addOption('ctrl', t('intercept_mode_ctrl'));
drop.addOption('shift', t('intercept_mode_shift'));
drop.addOption('disabled', t('intercept_mode_disabled'));
drop.setValue(this.plugin.settings.interceptAllTagClicks);
drop.onChange(async (value) => {
this.plugin.settings.interceptAllTagClicks = value as InterceptMode;
await this.plugin.saveSettings();
});
});
// 攔截Breadcrumb點擊事件
new Setting(sectionEl)
.setName(t('intercept_breadcrumb_clicks'))
.setDesc(t('intercept_breadcrumb_clicks_desc'))
.addToggle(toggle => {
toggle
.setValue(this.plugin.settings.interceptBreadcrumbClicks)
.onChange(async (value) => {
this.plugin.settings.interceptBreadcrumbClicks = value;
await this.plugin.saveSettings();
});
.addDropdown(drop => {
drop.addOption('enabled', t('intercept_mode_enabled'));
drop.addOption('alt', t('intercept_mode_alt'));
drop.addOption('ctrl', t('intercept_mode_ctrl'));
drop.addOption('shift', t('intercept_mode_shift'));
drop.addOption('disabled', t('intercept_mode_disabled'));
drop.setValue(this.plugin.settings.interceptBreadcrumbClicks);
drop.onChange(async (value) => {
this.plugin.settings.interceptBreadcrumbClicks = value as InterceptMode;
await this.plugin.saveSettings();
});
});

View file

@ -140,9 +140,14 @@ export default {
'enable_file_watcher': 'Enable file watcher',
'enable_file_watcher_desc': 'When enabled, the view will automatically update when files change. If disabled, you need to click the refresh button manually',
'intercept_all_tag_clicks': 'Intercept all tag clicks',
'intercept_all_tag_clicks_desc': 'When enabled, all tag clicks will be intercepted and opened in the grid view',
'intercept_all_tag_clicks_desc': 'Configure how tag click interception is triggered to open tags in grid view',
'intercept_breadcrumb_clicks': 'Intercept breadcrumb clicks',
'intercept_breadcrumb_clicks_desc': 'When enabled, breadcrumb clicks will be intercepted and the path will be opened in the grid view',
'intercept_breadcrumb_clicks_desc': 'Configure how breadcrumb click interception is triggered to open the folder path in grid view',
'intercept_mode_enabled': 'Intercept click',
'intercept_mode_alt': 'Click with Alt',
'intercept_mode_ctrl': 'Click with Ctrl',
'intercept_mode_shift': 'Click with Shift',
'intercept_mode_disabled': 'No intercept',
'reset_to_default': 'Reset to default',
'settings_reset_notice': 'Settings have been reset to default values',
'config_management': 'Config management',
@ -333,4 +338,5 @@ export default {
'add_to_stash': 'Add to stash',
'added_to_stash': 'Added to stash',
'import_fail_notice': 'Import failed',
'failed_to_move_file': 'Failed to move file',
};

View file

@ -139,9 +139,14 @@ export default {
'enable_file_watcher': 'ファイル監視を有効にする',
'enable_file_watcher_desc': '有効にすると、ファイルの変更を自動的に検出してビューを更新します。無効の場合は、更新ボタンを手動でクリックする必要があります',
'intercept_all_tag_clicks': 'すべてのタグクリックをインターセプト',
'intercept_all_tag_clicks_desc': '有効にすると、すべてのタグクリックがインターセプトされ、グリッドビューで開かれます',
'intercept_all_tag_clicks_desc': 'タグクリックのインターセプトをトリガーする方法を設定し、グリッドビューで開きます',
'intercept_breadcrumb_clicks': 'パンくずリストのクリックをインターセプト',
'intercept_breadcrumb_clicks_desc': '有効にすると、パンくずリストのクリックがインターセプトされ、パスがグリッドビューで開かれます',
'intercept_breadcrumb_clicks_desc': 'パンくずリストのクリックのインターセプトをトリガーする方法を設定し、グリッドビューで開きます',
'intercept_mode_enabled': 'クリックをインターセプト',
'intercept_mode_alt': 'Altを押しながらクリック',
'intercept_mode_ctrl': 'Ctrlを押しながらクリック',
'intercept_mode_shift': 'Shiftを押しながらクリック',
'intercept_mode_disabled': 'インターセプトしない',
'reset_to_default': 'デフォルトに戻す',
'settings_reset_notice': '設定値がデフォルト値にリセットされました',
'config_management': '設定管理',

View file

@ -140,9 +140,14 @@ export default {
'enable_file_watcher': '파일 감시 활성화',
'enable_file_watcher_desc': '활성화 시 파일 변경 시 뷰가 자동으로 업데이트됩니다. 비활성화 시 새로고침 버튼을 수동으로 클릭해야 합니다',
'intercept_all_tag_clicks': '모든 태그 클릭 가로채기',
'intercept_all_tag_clicks_desc': '활성화 시 모든 태그 클릭이 가로채져 그리드 뷰에서 열립니다',
'intercept_all_tag_clicks_desc': '태그 클릭 가로채기 트리거 방식을 설정하여 그리드 뷰에서 엽니다',
'intercept_breadcrumb_clicks': '브레드크럼 클릭 가로채기',
'intercept_breadcrumb_clicks_desc': '활성화 시 브레드크럼 클릭이 가로채져 경로가 그리드 뷰에서 열립니다',
'intercept_breadcrumb_clicks_desc': '브레드크럼 클릭 가로채기 트리거 방식을 설정하여 경로를 그리드 뷰에서 엽니다',
'intercept_mode_enabled': '클릭 가로채기',
'intercept_mode_alt': 'Alt 누르고 클릭',
'intercept_mode_ctrl': 'Ctrl 누르고 클릭',
'intercept_mode_shift': 'Shift 누르고 클릭',
'intercept_mode_disabled': '가로채지 않음',
'reset_to_default': '기본값으로 재설정',
'settings_reset_notice': '설정이 기본값으로 재설정되었습니다',
'config_management': '구성 관리',

View file

@ -139,9 +139,14 @@ export default {
'enable_file_watcher': 'Включить наблюдение за файлами',
'enable_file_watcher_desc': 'При включении вид будет автоматически обновляться при изменении файлов. Если отключено, нужно вручную нажимать кнопку обновления',
'intercept_all_tag_clicks': 'Перехватывать все клики по тегам',
'intercept_all_tag_clicks_desc': 'При включении все клики по тегам будут перехватываться и открываться в виде сетки',
'intercept_all_tag_clicks_desc': 'Настройте способ перехвата кликов по тегам для открытия в виде сетки',
'intercept_breadcrumb_clicks': 'Перехватывать клики по навигационной цепочке',
'intercept_breadcrumb_clicks_desc': 'При включении клики по навигационной цепочке будут перехватываться, и путь будет открываться в виде сетки',
'intercept_breadcrumb_clicks_desc': 'Настройте способ перехвата кликов по навигационной цепочке для открытия пути в виде сетки',
'intercept_mode_enabled': 'Перехватывать клик',
'intercept_mode_alt': 'Клик с Alt',
'intercept_mode_ctrl': 'Клик с Ctrl',
'intercept_mode_shift': 'Клик с Shift',
'intercept_mode_disabled': 'Не перехватывать',
'reset_to_default': 'Сбросить на значения по умолчанию',
'settings_reset_notice': 'Настройки сброшены на значения по умолчанию',
'config_management': 'Конфигурация управления',

View file

@ -139,9 +139,14 @@ export default {
'enable_file_watcher': 'Увімкнути спостереження за файлами',
'enable_file_watcher_desc': 'Коли увімкнено, вигляд автоматично оновлюватиметься при зміні файлів. Якщо вимкнено, потрібно вручну натискати кнопку оновлення',
'intercept_all_tag_clicks': 'Перехоплювати всі кліки по тегах',
'intercept_all_tag_clicks_desc': 'Коли увімкнено, всі кліки по тегах перехоплюватимуться і відкриватимуться в сітковому вигляді',
'intercept_all_tag_clicks_desc': 'Налаштуйте спосіб перехоплення кліків по тегах для відкриття в сітковому вигляді',
'intercept_breadcrumb_clicks': 'Перехоплювати кліки по навігаційному ланцюжку',
'intercept_breadcrumb_clicks_desc': 'Коли увімкнено, кліки по навігаційному ланцюжку перехоплюватимуться, і шлях відкриватиметься в сітковому вигляді',
'intercept_breadcrumb_clicks_desc': 'Налаштуйте спосіб перехоплення кліків по навігаційному ланцюжку для відкриття шляху в сітковому вигляді',
'intercept_mode_enabled': 'Перехоплювати клік',
'intercept_mode_alt': 'Клік з Alt',
'intercept_mode_ctrl': 'Клік з Ctrl',
'intercept_mode_shift': 'Клік з Shift',
'intercept_mode_disabled': 'Не перехоплювати',
'reset_to_default': 'Скинути до значень за замовчуванням',
'settings_reset_notice': 'Налаштування скинуто до значень за замовчуванням',
'config_management': 'Управління конфігурацією',

View file

@ -140,9 +140,14 @@ export default {
'enable_file_watcher': '啟用檔案監控',
'enable_file_watcher_desc': '啟用後會自動偵測檔案變更並更新視圖,關閉後需手動點擊重新整理按鈕',
'intercept_all_tag_clicks': '攔截所有標籤點擊事件',
'intercept_all_tag_clicks_desc': '啟用後會攔截所有標籤點擊事件,並在網格視圖中打開標籤',
'intercept_all_tag_clicks_desc': '設定攔截標籤點擊事件的觸發方式,並在網格視圖中打開標籤',
'intercept_breadcrumb_clicks': '攔截筆記導航列點擊事件',
'intercept_breadcrumb_clicks_desc': '啟用後會攔截筆記導航列點擊事件,並在網格視圖中打開路徑',
'intercept_breadcrumb_clicks_desc': '設定攔截筆記導航列點擊事件的觸發方式,並在網格視圖中打開路徑',
'intercept_mode_enabled': '攔截點擊',
'intercept_mode_alt': '按著Alt點擊',
'intercept_mode_ctrl': '按著Ctrl點擊',
'intercept_mode_shift': '按著Shift點擊',
'intercept_mode_disabled': '不攔截',
'reset_to_default': '重置為預設值',
'settings_reset_notice': '設定值已重置為預設值',
'config_management': '設定檔管理',
@ -333,4 +338,5 @@ export default {
'add_to_stash': '加入暫存區',
'added_to_stash': '已添加到暫存區',
'import_fail_notice': '匯入失敗',
'failed_to_move_file': '搬移檔案失敗',
};

View file

@ -139,9 +139,14 @@ export default {
'enable_file_watcher': '启用文件监控',
'enable_file_watcher_desc': '启用后会自动检测文件变更并更新视图,关闭后需手动点击刷新按钮',
'intercept_all_tag_clicks': '拦截所有标签点击事件',
'intercept_all_tag_clicks_desc': '启用后会拦截所有标签点击事件,改为在网格视图中打开标签',
'intercept_all_tag_clicks_desc': '设置拦截标签点击事件的触发方式,并在网格视图中打开标签',
'intercept_breadcrumb_clicks': '拦截笔记导航栏点击事件',
'intercept_breadcrumb_clicks_desc': '启用后会拦截笔记导航栏点击事件,并在网格视图中打开路径',
'intercept_breadcrumb_clicks_desc': '设置拦截笔记导航栏点击事件的触发方式,并在网格视图中打开路径',
'intercept_mode_enabled': '拦截点击',
'intercept_mode_alt': '按住Alt点击',
'intercept_mode_ctrl': '按住Ctrl点击',
'intercept_mode_shift': '按住Shift点击',
'intercept_mode_disabled': '不拦截',
'reset_to_default': '重置为默认值',
'settings_reset_notice': '设置值已重置为默认值',
'config_management': '配置管理',
@ -332,4 +337,5 @@ export default {
'add_to_stash': '加入暂存区',
'added_to_stash': '已添加到暂存区',
'import_fail_notice': '导入失败',
'failed_to_move_file': '搬移文件失败',
};

View file

@ -365,17 +365,20 @@ export async function getFiles(gridView: GridView, includeMediaFiles: boolean |
return [];
} else if (sourceMode === 'backlinks') {
// 反向連結模式:找出所有引用當前筆記的檔案
const activeFile = app.workspace.getActiveFile();
if (!activeFile) {
// 注意:不可使用 app.workspace.getActiveFile(),因為當 GridView 本身成為
// active leaf 時getActiveFile() 可能回傳 null 或不可靠的結果。
// 目標檔案應在切換到此模式時記錄於 gridView.sourcePath。
const targetFile = app.vault.getAbstractFileByPath(gridView.sourcePath);
if (!(targetFile instanceof TFile)) {
return [];
}
const backlinks = new Set();
// 使用 resolvedLinks 來找出反向連結
const resolvedLinks = app.metadataCache.resolvedLinks;
for (const [sourcePath, links] of Object.entries(resolvedLinks)) {
if (Object.keys(links).includes(activeFile.path)) {
const sourceFile = app.vault.getAbstractFileByPath(sourcePath);
for (const [resolvedSourcePath, links] of Object.entries(resolvedLinks)) {
if (Object.keys(links).includes(targetFile.path)) {
const sourceFile = app.vault.getAbstractFileByPath(resolvedSourcePath);
if (sourceFile instanceof TFile) {
backlinks.add(sourceFile);
}
@ -385,22 +388,23 @@ export async function getFiles(gridView: GridView, includeMediaFiles: boolean |
return sortFiles(Array.from(backlinks) as TFile[], gridView);
} else if (sourceMode === 'outgoinglinks') {
// 外部連結模式:找出當前筆記所引用的檔案,並包含所有媒體連結
const activeFile = app.workspace.getActiveFile();
if (!activeFile) {
// 同樣改用 gridView.sourcePath理由同上
const targetFile = app.vault.getAbstractFileByPath(gridView.sourcePath);
if (!(targetFile instanceof TFile)) {
return [];
}
const outgoingLinks = new Set<TFile>();
// 使用 resolvedLinks 來找出外部連結
const resolvedLinks = app.metadataCache.resolvedLinks;
const fileLinks = resolvedLinks[activeFile.path];
const fileLinks = resolvedLinks[targetFile.path];
if (fileLinks) {
for (const targetPath of Object.keys(fileLinks)) {
const targetFile = app.vault.getAbstractFileByPath(targetPath);
if (targetFile instanceof TFile && (isDocumentFile(targetFile) ||
(settings.showMediaFiles && isMediaFile(targetFile)))) {
outgoingLinks.add(targetFile);
const linkedFile = app.vault.getAbstractFileByPath(targetPath);
if (linkedFile instanceof TFile && (isDocumentFile(linkedFile) ||
(settings.showMediaFiles && isMediaFile(linkedFile)))) {
outgoingLinks.add(linkedFile);
}
}
}
@ -408,7 +412,7 @@ export async function getFiles(gridView: GridView, includeMediaFiles: boolean |
// 讀取目前筆記內容,找出所有媒體連結
if (settings.showMediaFiles) {
try {
const content = await app.vault.cachedRead(activeFile);
const content = await app.vault.cachedRead(targetFile);
// 去除 frontmatter
const frontMatterInfo = getFrontMatterInfo(content);
const contentWithoutFrontmatter = content.substring(frontMatterInfo.contentStart);
@ -422,7 +426,7 @@ export async function getFiles(gridView: GridView, includeMediaFiles: boolean |
// 處理內部連結路徑(去除 | 之後的部分)
mediaPath = mediaPath.split('|')[0].trim();
// 取得對應的 TFile
const mediaFile = app.metadataCache.getFirstLinkpathDest(mediaPath, activeFile.path);
const mediaFile = app.metadataCache.getFirstLinkpathDest(mediaPath, targetFile.path);
if (mediaFile && isMediaFile(mediaFile)) {
outgoingLinks.add(mediaFile);
}
@ -580,7 +584,12 @@ export async function getFiles(gridView: GridView, includeMediaFiles: boolean |
// 所有筆記模式
const allVaultFiles = app.vault.getFiles().filter(file => {
if (includeMediaFiles === 'media-only') {
return settings.showMediaFiles && isMediaFile(file);
if (settings.showMediaFiles) {
return isMediaFile(file);
} else {
// 當不顯示媒體但模式被切換為「只顯示媒體檔案」時,回退顯示筆記檔案
return isDocumentFile(file);
}
}
// 根據設定決定是否包含媒體檔案
if (isDocumentFile(file) ||
@ -594,7 +603,12 @@ export async function getFiles(gridView: GridView, includeMediaFiles: boolean |
// 最近檔案模式
const recentFiles = app.vault.getFiles().filter(file => {
if (includeMediaFiles === 'media-only') {
return settings.showMediaFiles && isMediaFile(file);
if (settings.showMediaFiles) {
return isMediaFile(file);
} else {
// 當不顯示媒體但模式被切換為「只顯示媒體檔案」時,回退顯示筆記檔案
return isDocumentFile(file);
}
}
// 根據設定決定是否包含媒體檔案
if (isDocumentFile(file) ||
@ -609,7 +623,12 @@ export async function getFiles(gridView: GridView, includeMediaFiles: boolean |
// 隨機筆記模式,從所有筆記中隨機選取
const randomFiles = app.vault.getFiles().filter(file => {
if (includeMediaFiles === 'media-only') {
return settings.showMediaFiles && isMediaFile(file);
if (settings.showMediaFiles) {
return isMediaFile(file);
} else {
// 當不顯示媒體但模式被切換為「只顯示媒體檔案」時,回退顯示筆記檔案
return isDocumentFile(file);
}
}
// 根據設定決定是否包含媒體檔案
if (isDocumentFile(file) ||

View file

@ -61,7 +61,7 @@ export async function getFirstImageFromZip(app: App, file: TFile): Promise<strin
export async function findFirstImageInNote(app: App, content: string): Promise<string | null> {
try {
const pluginAccess = app as unknown as AppPluginAccess;
const customDocumentExtensions = pluginAccess.plugins?.plugins?.['obsidian-gridexplorer']?.settings?.customDocumentExtensions;
const customDocumentExtensions = pluginAccess.plugins?.plugins?.['gridexplorer']?.settings?.customDocumentExtensions;
const isZipEnabled = customDocumentExtensions
?.split(',')
.map((ext: string) => ext.trim().toLowerCase())
@ -186,21 +186,24 @@ async function resolveUrl(app: App, url: string): Promise<string | null> {
}
async function validateRemoteImage(url: string): Promise<string | null> {
const headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36'
};
try {
const headResponse = await requestUrl({ url, method: 'HEAD' });
const headResponse = await requestUrl({ url, method: 'HEAD', headers });
if (isSuccessfulStatus(headResponse)) {
return url;
}
if (headResponse.status === 405 || headResponse.status === 501) {
const getResponse = await requestUrl({ url });
const getResponse = await requestUrl({ url, headers });
if (isSuccessfulStatus(getResponse)) {
return url;
}
}
} catch {
try {
const getResponse = await requestUrl({ url });
const getResponse = await requestUrl({ url, headers });
if (isSuccessfulStatus(getResponse)) {
return url;
}
@ -209,7 +212,8 @@ async function validateRemoteImage(url: string): Promise<string | null> {
}
}
return null;
// 即使驗證失敗,只要 URL 格式符合圖片規格,仍傳回原網址讓瀏覽器嘗試載入
return url;
}
function isSuccessfulStatus(response: RequestUrlResponse): boolean {

View file

@ -12,7 +12,7 @@
.ge-grid-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(var(--grid-item-width, 300px), 1fr));
gap: 10px;
gap: 11px;
align-items: start;
align-content: start;
flex: 1;
@ -20,6 +20,10 @@
}
.ge-grid-view-container .ge-grid-container {
padding: 14px;
}
:is(.mod-left-split, .mod-right-split) .ge-grid-view-container .ge-grid-container {
padding: 10px;
}
@ -150,8 +154,8 @@
.ge-title.ge-multiline-title {
white-space: normal;
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
line-height: 1.3;
overflow: hidden;
@ -171,6 +175,13 @@
-webkit-box-orient: vertical;
}
.mod-left-split .ge-grid-container .ge-grid-item .ge-content-area p,
.mod-right-split .ge-grid-container .ge-grid-item .ge-content-area p,
.workspace-drawer .ge-grid-container .ge-grid-item .ge-content-area p {
-webkit-line-clamp: 2;
line-clamp: 2;
}
.ge-content-area p:empty {
margin: 0;
}
@ -228,7 +239,7 @@
}
.ge-non-doc-icon.ge-zip {
color: var(--color-folder); /* obsidian color or fallback */
color: var(--color-yellow);
}
@ -457,53 +468,77 @@
}
/* 資料夾和筆記顏色 Folder & Note Colors */
.ge-grid-item.ge-folder-item.ge-folder-color-red,
.ge-grid-item.ge-note-color-red {
.ge-grid-item.ge-folder-item.ge-folder-color-red {
background-color: rgba(var(--color-red-rgb), 0.2);
border-color: rgba(var(--color-red-rgb), 0.5);
}
.ge-grid-item.ge-note-color-red {
background-color: rgba(var(--color-red-rgb), 0.2);
border-color: rgba(var(--color-red-rgb), 0.25);
}
.ge-grid-item.ge-folder-item.ge-folder-color-orange,
.ge-grid-item.ge-note-color-orange {
.ge-grid-item.ge-folder-item.ge-folder-color-orange {
background-color: rgba(var(--color-orange-rgb), 0.2);
border-color: rgba(var(--color-orange-rgb), 0.5);
}
.ge-grid-item.ge-note-color-orange {
background-color: rgba(var(--color-orange-rgb), 0.2);
border-color: rgba(var(--color-orange-rgb), 0.25);
}
.ge-grid-item.ge-folder-item.ge-folder-color-yellow,
.ge-grid-item.ge-note-color-yellow {
.ge-grid-item.ge-folder-item.ge-folder-color-yellow {
background-color: rgba(var(--color-yellow-rgb), 0.2);
border-color: rgba(var(--color-yellow-rgb), 0.5);
}
.ge-grid-item.ge-note-color-yellow {
background-color: rgba(var(--color-yellow-rgb), 0.2);
border-color: rgba(var(--color-yellow-rgb), 0.25);
}
.ge-grid-item.ge-folder-item.ge-folder-color-green,
.ge-grid-item.ge-note-color-green {
.ge-grid-item.ge-folder-item.ge-folder-color-green {
background-color: rgba(var(--color-green-rgb), 0.2);
border-color: rgba(var(--color-green-rgb), 0.5);
}
.ge-grid-item.ge-note-color-green {
background-color: rgba(var(--color-green-rgb), 0.2);
border-color: rgba(var(--color-green-rgb), 0.25);
}
.ge-grid-item.ge-folder-item.ge-folder-color-cyan,
.ge-grid-item.ge-note-color-cyan {
.ge-grid-item.ge-folder-item.ge-folder-color-cyan {
background-color: rgba(var(--color-cyan-rgb), 0.2);
border-color: rgba(var(--color-cyan-rgb), 0.5);
}
.ge-grid-item.ge-note-color-cyan {
background-color: rgba(var(--color-cyan-rgb), 0.2);
border-color: rgba(var(--color-cyan-rgb), 0.25);
}
.ge-grid-item.ge-folder-item.ge-folder-color-blue,
.ge-grid-item.ge-note-color-blue {
.ge-grid-item.ge-folder-item.ge-folder-color-blue {
background-color: rgba(var(--color-blue-rgb), 0.2);
border-color: rgba(var(--color-blue-rgb), 0.5);
}
.ge-grid-item.ge-note-color-blue {
background-color: rgba(var(--color-blue-rgb), 0.2);
border-color: rgba(var(--color-blue-rgb), 0.25);
}
.ge-grid-item.ge-folder-item.ge-folder-color-purple,
.ge-grid-item.ge-note-color-purple {
.ge-grid-item.ge-folder-item.ge-folder-color-purple {
background-color: rgba(var(--color-purple-rgb), 0.2);
border-color: rgba(var(--color-purple-rgb), 0.5);
}
.ge-grid-item.ge-note-color-purple {
background-color: rgba(var(--color-purple-rgb), 0.2);
border-color: rgba(var(--color-purple-rgb), 0.25);
}
.ge-grid-item.ge-folder-item.ge-folder-color-pink,
.ge-grid-item.ge-note-color-pink {
.ge-grid-item.ge-folder-item.ge-folder-color-pink {
background-color: rgba(var(--color-pink-rgb), 0.2);
border-color: rgba(var(--color-pink-rgb), 0.5);
}
.ge-grid-item.ge-note-color-pink {
background-color: rgba(var(--color-pink-rgb), 0.2);
border-color: rgba(var(--color-pink-rgb), 0.25);
}
/* 筆記文字顏色 Note Text Color */
.ge-content-area p.ge-note-color-red-text {
@ -2288,6 +2323,7 @@ a.ge-current-folder:hover {
z-index: 100;
display: flex;
flex-direction: column;
container-type: inline-size;
}
/* 筆記頂部按鈕列 */
@ -2319,7 +2355,8 @@ a.ge-current-folder:hover {
/* 捲動區域 */
.ge-note-scroll-container {
flex: 1 1 auto;
overflow: auto;
overflow-y: auto;
overflow-x: hidden;
background-color: var(--background-primary);
font-size: var(--font-text-size);
}
@ -2375,7 +2412,15 @@ a.ge-current-folder:hover {
min-height: 100%;
}
.is-mobile .ge-note-content-container {
:is(.mod-left-split, .mod-right-split) .ge-note-content-container {
padding: 20px;
}
.is-tablet .ge-note-content-container {
padding-top: 20px;
}
.is-phone .ge-note-content-container {
padding-top: 15px;
}
@ -2414,6 +2459,21 @@ a.ge-current-folder:hover {
pointer-events: auto;
}
/* 讓圖片延展到包含卷軸的介面寬度 (僅限行動裝置,且排除程式碼區塊、表格、清單等特殊容器內的圖片) */
.is-mobile .ge-note-content img:not(pre *, code *, [class*="block-language-"] *, .callout *, table *, ul *, ol *, blockquote *),
.is-mobile .ge-note-content .internal-embed:has(img):not(pre *, code *, [class*="block-language-"] *, .callout *, table *, ul *, ol *, blockquote *) {
width: 100cqw;
max-width: 100cqw;
margin-left: calc(50% - 50cqw);
display: block;
}
.is-mobile .ge-note-content .internal-embed:has(img):not(pre *, code *, [class*="block-language-"] *, .callout *, table *, ul *, ol *, blockquote *) img {
width: 100%;
max-width: 100%;
margin-left: 0;
}
.ge-note-content h1,
.ge-note-content h2,
.ge-note-content h3,
@ -3037,15 +3097,22 @@ a.ge-current-folder:hover {
/* --------------------------------------------------------------------------- */
/* 手機版頂部邊距適配 (針對 Obsidian 更新後的介面調整) */
.is-phone .ge-grid-view-container,
.is-phone .ge-note-view-container {
/* 讓整個插件容器避開頂部安全區域 (狀態列/瀏海) */
/* Obsidian 更新後將此變數用於處理頂部間距 */
.is-phone .ge-grid-view-container:not(.mod-left-split *, .mod-right-split *, .workspace-drawer *),
.is-phone .ge-note-view-container:not(.mod-left-split *, .mod-right-split *, .workspace-drawer *) {
/* 讓非側邊欄的主工作區插件容器避開頂部安全區域 (狀態列/瀏海) */
padding-top: var(--safe-area-inset-top);
padding-left: var(--safe-area-inset-left);
padding-right: var(--safe-area-inset-right);
}
/* 平板上的右側邊欄或底部/側邊抽屜加上頂部 5px 邊距 */
.is-mobile .mod-right-split .ge-grid-view-container,
.is-mobile .mod-right-split .ge-note-view-container,
.is-mobile .workspace-drawer .ge-grid-view-container,
.is-mobile .workspace-drawer .ge-note-view-container {
padding-top: 4px;
}
.is-phone .ge-grid-view-container .ge-grid-container {
padding-bottom: calc(var(--view-bottom-spacing) + var(--size-4-3));
}