devon22_obsidian-gridexplorer/main.ts
2025-06-25 00:13:51 +08:00

506 lines
No EOL
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Plugin, TFolder, TFile, App, Menu } from 'obsidian';
import { GridView } from './src/GridView';
import { showFolderSelectionModal } from './src/FolderSelectionModal';
import { showNoteSettingsModal } from './src/NoteSettingsModal';
import { GallerySettings, DEFAULT_SETTINGS, GridExplorerSettingTab } from './src/settings';
import { t } from './src/translations';
import { updateCustomDocumentExtensions } from './src/fileUtils';
export default class GridExplorerPlugin extends Plugin {
settings: GallerySettings;
statusBarItem: HTMLElement;
app: App;
async onload() {
await this.loadSettings();
// 註冊視圖類型
this.registerView(
'grid-view',
(leaf) => new GridView(leaf, this)
);
// 註冊設定頁面
this.addSettingTab(new GridExplorerSettingTab(this.app, this));
// 註冊開啟視圖指令
this.addCommand({
id: 'open-grid-view',
name: t('open_grid_view'),
callback: () => {
showFolderSelectionModal(this.app, this);
}
});
// 註冊開啟當前筆記指令
this.addCommand({
id: 'view-current-note-in-grid-view',
name: t('open_note_in_grid_view'),
callback: () => {
const activeFile = this.app.workspace.getActiveFile();
if (activeFile) {
this.openNoteInFolder(activeFile);
} else {
// 如果沒有當前筆記,則打開根目錄
this.openNoteInFolder(this.app.vault.getRoot());
}
}
});
// 註冊開啟反向連結指令
this.addCommand({
id: 'view-backlinks-in-grid-view',
name: t('open_backlinks_in_grid_view'),
callback: () => {
const activeFile = this.app.workspace.getActiveFile();
if (activeFile) {
this.activateView('backlinks');
} else {
// 如果沒有當前筆記,則打開根目錄
this.openNoteInFolder(this.app.vault.getRoot());
}
}
});
// 註冊開啟外部連結指令
this.addCommand({
id: 'view-outgoinglinks-in-grid-view',
name: t('open_outgoinglinks_in_grid_view'),
callback: () => {
const activeFile = this.app.workspace.getActiveFile();
if (activeFile) {
this.activateView('outgoinglinks');
} else {
// 如果沒有當前筆記,則打開根目錄
this.openNoteInFolder(this.app.vault.getRoot());
}
}
});
// 註冊開啟最近筆記指令
this.addCommand({
id: 'view-recent-files-in-grid-view',
name: t('open_recent_files_in_grid_view'),
callback: () => {
const activeFile = this.app.workspace.getActiveFile();
if (activeFile) {
this.openNoteInRecentFiles(activeFile);
} else {
// 如果沒有當前筆記,則打開根目錄
this.openNoteInFolder(this.app.vault.getRoot());
}
}
});
// 新增 Ribbon 圖示
this.addRibbonIcon('grid', t('open_grid_view'), () => {
showFolderSelectionModal(this.app, this);
});
// 註冊狀態列項目
this.statusBarItem = this.addStatusBarItem();
// this.statusBarItem.onClickEvent(() => {
// this.activateView();
// });
// 註冊資料夾的右鍵選單
this.registerEvent(
this.app.workspace.on('file-menu', (menu: Menu, file) => {
if (file instanceof TFolder) {
menu.addItem(item => {
item
.setTitle(t('open_in_grid_view'))
.setIcon('grid')
.setSection?.("open")
.onClick(() => {
this.openNoteInFolder(file);
});
});
}
if (file instanceof TFile) {
menu.addItem(item => {
item.setTitle(t('open_in_grid_view'));
item.setIcon('grid');
item.setSection?.("open");
const ogSubmenu: Menu = (item as any).setSubmenu();
ogSubmenu.addItem((item) => {
item
.setTitle(t('open_note_in_grid_view'))
.setIcon('folder')
.onClick(() => {
this.openNoteInFolder(file);
});
});
if (this.settings.showBacklinksMode) {
ogSubmenu.addItem((item) => {
item
.setTitle(t('open_backlinks_in_grid_view'))
.setIcon('links-coming-in')
.onClick(() => {
this.app.workspace.getLeaf().openFile(file);
setTimeout(() => {
this.activateView('backlinks');
}, 100);
});
});
}
if (this.settings.showOutgoinglinksMode) {
ogSubmenu.addItem((item) => {
item
.setTitle(t('open_outgoinglinks_in_grid_view'))
.setIcon('links-going-out')
.onClick(() => {
this.app.workspace.getLeaf().openFile(file);
setTimeout(() => {
this.activateView('outgoinglinks');
}, 100);
});
});
}
if (this.settings.showRecentFilesMode && file instanceof TFile) {
ogSubmenu.addItem((item) => {
item
.setTitle(t('open_recent_files_in_grid_view'))
.setIcon('calendar-days')
.onClick(() => {
this.openNoteInRecentFiles(file);
});
});
}
});
menu.addItem((item) => {
item
.setTitle(t('set_note_attribute'))
.setIcon('palette')
.onClick(() => {
showNoteSettingsModal(this.app, this, file);
});
});
}
})
);
// 註冊編輯器選單
this.registerEvent(
this.app.workspace.on('editor-menu', (menu: Menu, editor) => {
if (editor.somethingSelected()) {
const selectedText = editor.getSelection();
// 截斷過長的文字,最多顯示 20 個字元
const truncatedText = selectedText.length > 20 ? selectedText.substring(0, 20) + '...' : selectedText;
const menuItemTitle = t('search_selection_in_grid_view').replace('...', `${truncatedText}`); // 假設翻譯中有...代表要替換的部分,或者直接格式化
menu.addItem(item => {
item
.setTitle(menuItemTitle)
.setIcon('search')
.setSection?.("view")
.onClick(async () => {
const selectedText = editor.getSelection();
// 取得或啟用 GridView
const view = await this.activateView('','');
if (view instanceof GridView) {
// 設定搜尋模式和關鍵字
view.searchQuery = selectedText;
// 重新渲染視圖
view.render(true); // resetScroll = true
}
});
});
}
})
);
// 註冊tag-wrangler右鍵選單
this.registerEvent(
(this.app.workspace as any).on('tag-wrangler:contextmenu', (menu: Menu, tagName: string) => {
// 截斷過長的文字,最多顯示 20 個字元
const truncatedText = tagName.length > 20 ? tagName.substring(0, 20) + '...' : tagName;
const menuItemTitle = t('search_selection_in_grid_view').replace('...', `「#${truncatedText}`); // 假設翻譯中有...代表要替換的部分,或者直接格式化
menu.addItem(item => {
item
.setTitle(menuItemTitle)
.setIcon('search')
.setSection?.("view")
.onClick(async () => {
// 取得或啟用 GridView
const view = await this.activateView('','');
if (view instanceof GridView) {
// 設定搜尋模式和關鍵字
view.searchQuery = `#${tagName}`;
// 重新渲染視圖
view.render(true); // resetScroll = true
}
});
});
})
);
// 攔截所有tag點擊事件
this.registerDomEvent(document, 'click', async (evt: MouseEvent) => {
// 如果未啟用攔截所有tag點擊事件則跳過
if (!this.settings.interceptAllTagClicks) return;
// 只處理左鍵
if (evt.button !== 0) return;
// 若點擊的是屬性面板中 tag pill 的刪除按鈕,直接跳過
if ((evt.target as HTMLElement).closest('.multi-select-pill-remove-button')) return;
// 從觸發點往上找,看是否碰到 tag 連結
const el = (evt.target as HTMLElement).closest(
'a.tag, /* 預覽模式中的 #tag */\n' +
'.tag-pane-tag, /* 標籤面板中的 tag */\n' +
'span.cm-hashtag, /* 編輯器中的 tag */\n' +
'.metadata-property[data-property-key="tags"] .multi-select-pill /* 屬性面板中的 tag */'
) as HTMLElement | null;
if (!el) return; // 不是 tag 點擊就跳過
// 取出 tag 名稱(去掉前導 #
let tagName = '';
if (el.matches('span.cm-hashtag')) {
// 編輯器模式:可能被拆成多個 span需組合
const collect = [] as string[];
let curr: HTMLElement | null = el;
// 向左收集
while (curr && curr.matches('span.cm-hashtag') && !curr.classList.contains('cm-formatting')) {
collect.unshift(curr.textContent ?? '');
curr = curr.previousElementSibling as HTMLElement | null;
if (curr && (!curr.matches('span.cm-hashtag') || curr.classList.contains('cm-formatting'))) break;
}
// 向右收集
curr = el.nextElementSibling as HTMLElement | null;
while (curr && curr.matches('span.cm-hashtag') && !curr.classList.contains('cm-formatting')) {
collect.push(curr.textContent ?? '');
curr = curr.nextElementSibling as HTMLElement | null;
}
tagName = collect.join('').trim();
} else if (el.classList.contains('tag-pane-tag')) {
// Tag Pane 中的元素,需避開計數器
const inner = el.querySelector('.tag-pane-tag-text, .tree-item-inner-text') as HTMLElement | null;
tagName = inner?.textContent?.trim() ?? '';
} else if (el.matches('.metadata-property[data-property-key="tags"] .multi-select-pill')) {
// Frontmatter/Properties view
tagName = el.textContent?.trim() ?? '';
} else {
// 預覽模式中的連結 a.tag
tagName = (el.getAttribute('data-tag') ?? el.textContent ?? '').trim();
}
tagName = tagName.replace(/^#/, '');
if (!tagName) return;
// 阻止 Obsidian 自己的處理(開搜尋窗)
evt.preventDefault();
evt.stopPropagation();
// 叫出 GridView並把搜尋字串設成這個 tag
const view = await this.activateView('', '');
if (view instanceof GridView) {
view.searchQuery = `#${tagName}`;
view.render(true); // resetScroll
}
}, true); // 用 capture可在其他 listener 前先吃到
this.setupCanvasDropHandlers();
}
private setupCanvasDropHandlers() {
const setup = () => {
this.app.workspace.getLeavesOfType('canvas').forEach(leaf => {
const canvasView = leaf.view as any;
if (canvasView.gridExplorerDropHandler) {
return;
}
canvasView.gridExplorerDropHandler = true;
const canvasEl = canvasView.containerEl;
const dragoverHandler = (evt: DragEvent) => {
// 只處理來自 Grid Explorer 的檔案拖曳,以顯示正確的游標
if (evt.dataTransfer?.types.includes('application/obsidian-grid-explorer-files')) {
evt.preventDefault();
evt.dataTransfer.dropEffect = 'copy';
}
};
const dropHandler = async (evt: DragEvent) => {
// 只處理來自 Grid Explorer 的拖曳事件
if (!evt.dataTransfer?.types.includes('application/obsidian-grid-explorer-files')) {
return; // 如果不是,則不做任何事,讓事件繼續傳遞給 Canvas 的預設處理器
}
// 來自 Grid Explorer 的拖曳,處理它並阻止預設行為
evt.preventDefault();
evt.stopPropagation();
let filePath: string | undefined;
const data = evt.dataTransfer.getData('application/obsidian-grid-explorer-files');
try {
const paths = JSON.parse(data);
// 目前我們只支援單一檔案拖放
if (Array.isArray(paths) && paths.length === 1) {
filePath = paths[0];
}
} catch (e) {
console.error("Grid Explorer: Failed to parse drop data from Grid Explorer.", e);
}
// 如果無法取得檔案路徑,則中止操作
if (!filePath) {
return;
}
const tfile = this.app.vault.getAbstractFileByPath(filePath);
if (!(tfile instanceof TFile)) {
console.warn('Grid Explorer: Dropped item is not a TFile or could not be found.', filePath);
return;
}
const canvas = canvasView.canvas;
if (!canvas) {
return;
}
const pos = canvas.posFromEvt(evt);
const newNode = canvas.createFileNode({
file: tfile,
pos: pos,
size: { width: 400, height: 400 }, // 預設大小
focus: true, // 建立後自動對焦
});
canvas.addNode(newNode);
await canvas.requestSave();
};
this.registerDomEvent(canvasEl, 'dragover', dragoverHandler);
this.registerDomEvent(canvasEl, 'drop', dropHandler, true); // 使用捕獲階段,以優先處理事件
});
};
this.registerEvent(this.app.workspace.on('layout-change', setup));
setup(); // 首次執行設定
}
async openNoteInRecentFiles(file: TFile) {
const view = await this.activateView('recent-files') as GridView;
// 如果是文件,等待視圖渲染完成後捲動到該文件位置
if (file instanceof TFile) {
// 等待下一個事件循環以確保視圖已完全渲染
setTimeout(() => {
const gridContainer = view.containerEl.querySelector('.ge-grid-container') as HTMLElement;
if (!gridContainer) return;
// 找到對應的網格項目
const gridItem = Array.from(gridContainer.querySelectorAll('.ge-grid-item')).find(
item => (item as HTMLElement).dataset.filePath === file.path
) as HTMLElement;
if (gridItem) {
// 捲動到該項目的位置
gridItem.scrollIntoView({ behavior: 'smooth', block: 'center' });
// 選中該項目
const itemIndex = view.gridItems.indexOf(gridItem);
if (itemIndex >= 0) {
view.selectItem(itemIndex);
}
}
}, 100);
}
}
async openNoteInFolder(file: TFile | TFolder = this.app.vault.getRoot()) {
// 如果是文件,使用其父資料夾路徑
const folderPath = file ? (file instanceof TFile ? file.parent?.path : file.path) : "/";
const view = await this.activateView('folder', folderPath) as GridView;
// 如果是文件,等待視圖渲染完成後捲動到該文件位置
if (file instanceof TFile) {
// 等待下一個事件循環以確保視圖已完全渲染
setTimeout(() => {
const gridContainer = view.containerEl.querySelector('.ge-grid-container') as HTMLElement;
if (!gridContainer) return;
// 找到對應的網格項目
const gridItem = Array.from(gridContainer.querySelectorAll('.ge-grid-item')).find(
item => (item as HTMLElement).dataset.filePath === file.path
) as HTMLElement;
if (gridItem) {
// 捲動到該項目的位置
gridItem.scrollIntoView({ behavior: 'smooth', block: 'center' });
// 選中該項目
const itemIndex = view.gridItems.indexOf(gridItem);
if (itemIndex >= 0) {
view.selectItem(itemIndex);
}
}
}, 100);
}
}
async activateView(mode = 'folder', path = '') {
const { workspace } = this.app;
let leaf = null;
const leaves = workspace.getLeavesOfType('grid-view');
// 如果設定為重用現有的leaf且存在已開啟的 grid-view則使用第一個
if (this.settings.reuseExistingLeaf && leaves.length > 0) {
leaf = leaves[0];
} else {
// 根據設定選擇開啟位置
switch (this.settings.defaultOpenLocation) {
case 'left':
leaf = workspace.getLeftLeaf(false);
break;
case 'right':
leaf = workspace.getRightLeaf(false);
break;
case 'tab':
default:
leaf = workspace.getLeaf('tab');
break;
}
}
// 確保 leaf 不為 null
if (!leaf) {
// 如果無法獲取指定位置的 leaf則回退到新分頁
leaf = workspace.getLeaf('tab');
}
await leaf.setViewState({ type: 'grid-view', active: true });
// 設定資料來源
if (leaf.view instanceof GridView) {
await leaf.view.setSource(mode, path);
}
// 確保視圖是活躍的
workspace.revealLeaf(leaf);
return leaf.view;
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
updateCustomDocumentExtensions(this.settings);
}
async saveSettings(update = true) {
await this.saveData(this.settings);
updateCustomDocumentExtensions(this.settings);
// 當設定變更時,更新所有開啟的 GridView 實例
if (update) {
const leaves = this.app.workspace.getLeavesOfType('grid-view');
leaves.forEach(leaf => {
if (leaf.view instanceof GridView) {
// 重新渲染視圖以套用新設定
leaf.view.render();
}
});
}
}
}