This commit is contained in:
Devon22 2026-06-29 23:55:42 +08:00
parent f01a2cbf83
commit 9d81fd0f11
13 changed files with 283 additions and 251 deletions

View file

@ -1,7 +1,7 @@
{
"id": "gridexplorer",
"name": "GridExplorer",
"version": "3.4.10",
"version": "3.4.11",
"minAppVersion": "1.8.7",
"description": "Browse note files in a grid view.",
"author": "Devon22",

4
package-lock.json generated
View file

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

View file

@ -1,6 +1,6 @@
{
"name": "gridexplorer",
"version": "3.4.10",
"version": "3.4.11",
"description": "Browse note files in a grid view.",
"main": "main.js",
"scripts": {

View file

@ -68,7 +68,190 @@ export class GridPreviewManager {
}
}
// 註冊預覽視窗的行動裝置滑動手勢(包含上下拖曳關閉與左右滑動切換歷史記錄)
private registerPreviewTouchEvents(
container: HTMLElement,
scrollContainer: HTMLElement,
onClose: () => void
) {
if (!Platform.isMobile) return;
container.setCssProps({ touchAction: 'pan-y' });
let startY = 0;
let startX = 0;
let currentY = 0;
let currentX = 0;
let isPullingUp = false;
let isDragging = false;
let isHorizontalDragging = false;
let initialScrollTop = 0;
let isAtTop = false;
let isAtBottom = false;
const handleTouchStart = (e: TouchEvent) => {
if (e.touches.length > 1) return;
const target = e.target as HTMLElement;
if (target.closest('input') || target.closest('textarea')) {
return;
}
initialScrollTop = scrollContainer.scrollTop;
isAtTop = initialScrollTop <= 0;
isAtBottom = initialScrollTop + scrollContainer.clientHeight >= scrollContainer.scrollHeight - 1;
startY = e.touches[0].clientY;
currentY = startY;
startX = e.touches[0].clientX;
currentX = startX;
isDragging = false;
isPullingUp = false;
isHorizontalDragging = false;
};
const handleTouchMove = (e: TouchEvent) => {
if (e.touches.length > 1) return;
currentY = e.touches[0].clientY;
currentX = e.touches[0].clientX;
const deltaY = currentY - startY;
const deltaX = currentX - startX;
// 決定滑動方向
if (!isDragging && !isHorizontalDragging) {
const threshold = 10;
if (Math.abs(deltaX) > threshold || Math.abs(deltaY) > threshold) {
if (Math.abs(deltaX) > Math.abs(deltaY)) {
// 左右滑動
const hasForwardHistory = this.previewHistoryIndex < this.previewHistory.length - 1;
if (deltaX < 0 && !hasForwardHistory) {
// 禁用該方向,不設為 dragging
} else {
isHorizontalDragging = true;
container.setCssProps({ transition: 'none' });
}
} else {
// 上下滑動 (原有拉動關閉功能)
if (isAtTop || isAtBottom) {
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;
container.setCssProps({ transition: 'none' });
}
}
}
}
}
// 只有在進入我們自己定義的拖曳狀態時,才攔截事件與阻止預設行為
if (isHorizontalDragging || isDragging) {
e.stopPropagation();
if (e.cancelable) {
e.preventDefault();
}
}
// 處理左右滑動視覺反饋
if (isHorizontalDragging) {
const hasForwardHistory = this.previewHistoryIndex < this.previewHistory.length - 1;
let translateX = deltaX * 0.6;
if (!hasForwardHistory && translateX < 0) {
translateX = 0;
}
if (translateX !== 0) {
container.setCssProps({ transform: `translateX(${translateX}px)` });
}
}
// 處理上下滑動視覺反饋
if (isDragging) {
const resistance = 0.5;
const translateY = deltaY * resistance;
container.setCssProps({ transform: `translateY(${translateY}px)` });
}
};
const handleTouchEnd = (e: TouchEvent) => {
if (isHorizontalDragging || isDragging) {
e.stopPropagation();
}
const deltaX = currentX - startX;
const deltaY = currentY - startY;
if (isHorizontalDragging) {
isHorizontalDragging = false;
const swipeThreshold = 80;
const hasForwardHistory = this.previewHistoryIndex < this.previewHistory.length - 1;
const isValidSwipe = !(deltaX < 0 && !hasForwardHistory);
if (isValidSwipe && Math.abs(deltaX) > swipeThreshold) {
if (deltaX > 0) {
void this.navigatePreviewBack();
} else {
void this.navigatePreviewForward();
}
container.setCssProps({
transform: '',
transition: '',
});
} else {
container.setCssProps({
transition: 'transform 0.3s ease-out',
transform: 'translateX(0)',
});
window.setTimeout(() => {
container.setCssProps({
transform: '',
transition: '',
});
}, 300);
}
} else if (isDragging) {
isDragging = false;
const closeThreshold = isPullingUp ? 170 : 110;
if ((!isPullingUp && deltaY > closeThreshold) || (isPullingUp && deltaY < -closeThreshold)) {
const targetY = isPullingUp ? '-100vh' : '100vh';
container.setCssProps({
transition: 'transform 0.2s ease-out',
transform: `translateY(${targetY})`,
});
window.setTimeout(() => {
onClose();
container.setCssProps({
transform: '',
transition: '',
});
}, 200);
} else {
container.setCssProps({
transition: 'transform 0.3s ease-out',
transform: 'translateY(0)',
});
window.setTimeout(() => {
container.setCssProps({
transform: '',
transition: '',
});
}, 300);
}
}
};
container.addEventListener('touchstart', handleTouchStart, { capture: true, passive: false });
container.addEventListener('touchmove', handleTouchMove, { capture: true, passive: false });
container.addEventListener('touchend', handleTouchEnd, { capture: true });
}
// 在網格視圖中直接顯示筆記
async showNoteInGrid(file: TFile, isHistoryNavigation = false) {
@ -444,124 +627,8 @@ export class GridPreviewManager {
await this.renderLinksSection(file, noteContent);
// 行動裝置下拉或上拉關閉筆記
if (Platform.isMobile && noteViewContainer) {
let startY = 0;
let startX = 0;
let currentY = 0;
let isPulling = false;
let isPullingUp = false;
let isDragging = false;
let initialScrollTop = 0;
let isAtTop = false;
let isAtBottom = false;
const handleTouchStart = (e: TouchEvent) => {
const target = e.target as HTMLElement;
if (target.closest('button') || target.closest('a') || target.closest('input') || target.closest('textarea')) {
return;
}
initialScrollTop = scrollContainer.scrollTop;
isAtTop = initialScrollTop <= 0;
isAtBottom = initialScrollTop + scrollContainer.clientHeight >= scrollContainer.scrollHeight - 1;
if (isAtTop || isAtBottom) {
startY = e.touches[0].clientY;
currentY = startY;
startX = e.touches[0].clientX;
isPulling = true;
isDragging = false;
isPullingUp = false;
}
};
const handleTouchMove = (e: TouchEvent) => {
if (!isPulling || !noteViewContainer) return;
currentY = e.touches[0].clientY;
const currentX = e.touches[0].clientX;
const deltaY = currentY - startY;
const deltaX = currentX - startX;
if (!isDragging) {
if (Math.abs(deltaX) > Math.abs(deltaY)) {
isPulling = false;
return;
}
const pullDownStartThreshold = 24;
const pullUpStartThreshold = 36;
const canPullDown = isAtTop && deltaY > pullDownStartThreshold;
const canPullUp = isAtBottom && deltaY < -pullUpStartThreshold;
if (canPullDown || canPullUp) {
isDragging = true;
isPullingUp = canPullUp && deltaY < 0;
if (noteViewContainer) {
noteViewContainer.setCssProps({ transition: 'none' });
}
} else if ((isAtTop && !isAtBottom && deltaY < 0) || (isAtBottom && !isAtTop && deltaY > 0)) {
isPulling = false;
return;
}
}
if (isDragging) {
if (e.cancelable) {
e.preventDefault();
}
const resistance = 0.5;
const translateY = deltaY * resistance;
noteViewContainer.setCssProps({ transform: `translateY(${translateY}px)` });
}
};
const handleTouchEnd = () => {
if (!isPulling || !noteViewContainer) return;
isPulling = false;
if (!isDragging) return;
isDragging = false;
const deltaY = currentY - startY;
const closeThreshold = isPullingUp ? 170 : 110;
if ((!isPullingUp && deltaY > closeThreshold) || (isPullingUp && deltaY < -closeThreshold)) {
const targetY = isPullingUp ? '-100vh' : '100vh';
noteViewContainer.setCssProps({
transition: 'transform 0.2s ease-out',
transform: `translateY(${targetY})`,
});
window.setTimeout(() => {
this.hideNoteInGrid();
if (noteViewContainer) {
noteViewContainer.setCssProps({
transform: '',
transition: '',
});
}
}, 200);
} else {
noteViewContainer.setCssProps({
transition: 'transform 0.3s ease-out',
transform: 'translateY(0)',
});
window.setTimeout(() => {
if (noteViewContainer) {
noteViewContainer.setCssProps({
transform: '',
transition: '',
});
}
}, 300);
}
};
noteViewContainer.addEventListener('touchstart', handleTouchStart, { passive: true });
noteViewContainer.addEventListener('touchmove', handleTouchMove, { passive: false });
noteViewContainer.addEventListener('touchend', handleTouchEnd);
}
// 註冊行動裝置滑動手勢
this.registerPreviewTouchEvents(noteViewContainer, scrollContainer, () => this.hideNoteInGrid());
// 設定狀態
this.view.isShowingNote = true;
@ -866,124 +933,8 @@ export class GridPreviewManager {
console.error('Error loading ZIP content in grid:', error);
}
// 行動裝置下拉關閉
if (Platform.isMobile && zipViewContainer) {
let startY = 0;
let startX = 0;
let currentY = 0;
let isPulling = false;
let isPullingUp = false;
let isDragging = false;
let initialScrollTop = 0;
let isAtTop = false;
let isAtBottom = false;
const handleTouchStart = (e: TouchEvent) => {
const target = e.target as HTMLElement;
if (target.closest('button') || target.closest('a') || target.closest('input') || target.closest('textarea')) {
return;
}
initialScrollTop = scrollContainer.scrollTop;
isAtTop = initialScrollTop <= 0;
isAtBottom = initialScrollTop + scrollContainer.clientHeight >= scrollContainer.scrollHeight - 1;
if (isAtTop || isAtBottom) {
startY = e.touches[0].clientY;
currentY = startY;
startX = e.touches[0].clientX;
isPulling = true;
isDragging = false;
isPullingUp = false;
}
};
const handleTouchMove = (e: TouchEvent) => {
if (!isPulling || !zipViewContainer) return;
currentY = e.touches[0].clientY;
const currentX = e.touches[0].clientX;
const deltaY = currentY - startY;
const deltaX = currentX - startX;
if (!isDragging) {
if (Math.abs(deltaX) > Math.abs(deltaY)) {
isPulling = false;
return;
}
const pullDownStartThreshold = 24;
const pullUpStartThreshold = 36;
const canPullDown = isAtTop && deltaY > pullDownStartThreshold;
const canPullUp = isAtBottom && deltaY < -pullUpStartThreshold;
if (canPullDown || canPullUp) {
isDragging = true;
isPullingUp = canPullUp && deltaY < 0;
if (zipViewContainer) {
zipViewContainer.setCssProps({ transition: 'none' });
}
} else if ((isAtTop && !isAtBottom && deltaY < 0) || (isAtBottom && !isAtTop && deltaY > 0)) {
isPulling = false;
return;
}
}
if (isDragging) {
if (e.cancelable) {
e.preventDefault();
}
const resistance = 0.5;
const translateY = deltaY * resistance;
zipViewContainer.setCssProps({ transform: `translateY(${translateY}px)` });
}
};
const handleTouchEnd = () => {
if (!isPulling || !zipViewContainer) return;
isPulling = false;
if (!isDragging) return;
isDragging = false;
const deltaY = currentY - startY;
const closeThreshold = isPullingUp ? 170 : 110;
if ((!isPullingUp && deltaY > closeThreshold) || (isPullingUp && deltaY < -closeThreshold)) {
const targetY = isPullingUp ? '-100vh' : '100vh';
zipViewContainer.setCssProps({
transition: 'transform 0.2s ease-out',
transform: `translateY(${targetY})`,
});
window.setTimeout(() => {
this.hideZipInGrid();
if (zipViewContainer) {
zipViewContainer.setCssProps({
transform: '',
transition: '',
});
}
}, 200);
} else {
zipViewContainer.setCssProps({
transition: 'transform 0.3s ease-out',
transform: 'translateY(0)',
});
window.setTimeout(() => {
if (zipViewContainer) {
zipViewContainer.setCssProps({
transform: '',
transition: '',
});
}
}, 300);
}
};
zipViewContainer.addEventListener('touchstart', handleTouchStart, { passive: true });
zipViewContainer.addEventListener('touchmove', handleTouchMove, { passive: false });
zipViewContainer.addEventListener('touchend', handleTouchEnd);
}
// 註冊行動裝置滑動手勢
this.registerPreviewTouchEvents(zipViewContainer, scrollContainer, () => this.hideZipInGrid());
// 設定狀態
this.view.isShowingZip = true;

View file

@ -243,12 +243,12 @@ export class GridView extends ItemView {
if (this.app.workspace.getActiveViewOfType(GridView) !== this) return;
// 有 modal 時不處理
if (activeDocument.querySelector('.modal-container')) return;
// 阻止內建快捷鍵與其他監聽器
event.preventDefault();
// 停止後續所有監聽器(包含 Obsidian 內建 hotkey
event.stopImmediatePropagation();
if (this.isShowingNote || this.isShowingZip) {
void this.previewManager.navigatePreviewBack();
} else {
@ -260,12 +260,12 @@ export class GridView extends ItemView {
if (this.app.workspace.getActiveViewOfType(GridView) !== this) return;
// 有 modal 時不處理
if (activeDocument.querySelector('.modal-container')) return;
// 阻止內建快捷鍵與其他監聽器
event.preventDefault();
// 停止後續所有監聽器(包含 Obsidian 內建 hotkey
event.stopImmediatePropagation();
if (this.isShowingNote || this.isShowingZip) {
void this.previewManager.navigatePreviewForward();
} else {
@ -277,7 +277,7 @@ export class GridView extends ItemView {
if (this.app.workspace.getActiveViewOfType(GridView) !== this) return;
// 有 modal 時不處理
if (activeDocument.querySelector('.modal-container')) return;
// 若正在顯示預覽,則關閉預覽
if (this.isShowingNote || this.isShowingZip) {
event.preventDefault();
@ -325,6 +325,80 @@ export class GridView extends ItemView {
})
);
}
// 監聽外部檔案拖曳進來並複製到目前資料夾
this.registerDomEvent(this.containerEl, 'dragover', (event: DragEvent) => {
if (this.sourceMode === 'folder') {
const types = event.dataTransfer?.types || [];
// 確保為外部系統檔案拖曳,排除內部元素拖曳 (內部拖曳通常含有 text/html 或 text/plain)
const isExternalFile = types.includes('Files') && !types.includes('text/html') && !types.includes('text/plain');
if (isExternalFile) {
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
this.containerEl.addClass('ge-dragover');
}
}
});
this.registerDomEvent(this.containerEl, 'dragleave', () => {
this.containerEl.removeClass('ge-dragover');
});
this.registerDomEvent(this.containerEl, 'drop', async (event: DragEvent) => {
if (this.sourceMode !== 'folder') return;
this.containerEl.removeClass('ge-dragover');
if (!event.dataTransfer) return;
const types = event.dataTransfer.types || [];
const isExternalFile = types.includes('Files') && !types.includes('text/html') && !types.includes('text/plain');
// 如果不是外部檔案,直接返回(交給其他已經寫好搬移邏輯的處理器處理,不攔截)
if (!isExternalFile) return;
// 處理外部檔案的複製匯入 (Copy/Import)
event.preventDefault();
event.stopPropagation();
const files = event.dataTransfer.files;
if (!files || files.length === 0) return;
let importedCount = 0;
for (let i = 0; i < files.length; i++) {
const file = files[i];
try {
const arrayBuffer = await file.arrayBuffer();
// 處理檔名重複
let baseName = file.name;
let extension = '';
const lastDotIndex = file.name.lastIndexOf('.');
if (lastDotIndex !== -1) {
baseName = file.name.substring(0, lastDotIndex);
extension = file.name.substring(lastDotIndex);
}
let targetPath = normalizePath(`${this.sourcePath}/${file.name}`);
let counter = 1;
while (this.app.vault.getAbstractFileByPath(targetPath)) {
targetPath = normalizePath(`${this.sourcePath}/${baseName}_${counter}${extension}`);
counter++;
}
await this.app.vault.createBinary(targetPath, arrayBuffer);
importedCount++;
} catch (err) {
console.error(`GridExplorer: Failed to import file ${file.name}`, err);
new Notice(t('import_fail_notice'));
}
}
if (importedCount > 0) {
if (!this.fileWatcher) {
await this.render();
}
}
});
}
getViewType() {
@ -2162,7 +2236,7 @@ export class GridView extends ItemView {
} else {
copyPath = `${f.basename} 1.${f.extension}`;
}
let counter = 1;
while (this.app.vault.getAbstractFileByPath(copyPath)) {
counter++;
@ -2172,7 +2246,7 @@ export class GridView extends ItemView {
copyPath = `${f.basename} ${counter}.${f.extension}`;
}
}
try {
await this.app.vault.copy(f, copyPath);
} catch (err) {

View file

@ -332,4 +332,5 @@ export default {
'save_stash_as_markdown': 'Save stash as Markdown',
'add_to_stash': 'Add to stash',
'added_to_stash': 'Added to stash',
'import_fail_notice': 'Import failed',
};

View file

@ -331,4 +331,5 @@ export default {
'save_stash_as_markdown': '一時保存をMarkdownとして保存',
'add_to_stash': '一時保存に追加',
'added_to_stash': '一時保存に追加されました',
'import_fail_notice': 'インポートに失敗しました',
};

View file

@ -332,4 +332,5 @@ export default {
'save_stash_as_markdown': '보관함을 Markdown으로 저장',
'add_to_stash': '보관함에 추가',
'added_to_stash': '보관함에 추가됨',
'import_fail_notice': '가져오기 실패',
};

View file

@ -331,4 +331,5 @@ export default {
'save_stash_as_markdown': 'Сохранить хранилище как Markdown',
'add_to_stash': 'Добавить в хранилище',
'added_to_stash': 'Добавлено в хранилище',
'import_fail_notice': 'Ошибка импорта',
};

View file

@ -331,4 +331,5 @@ export default {
'save_stash_as_markdown': 'Зберегти сховище як Markdown',
'add_to_stash': 'Додати до сховища',
'added_to_stash': 'Додано до сховища',
'import_fail_notice': 'Помилка імпорту',
};

View file

@ -332,4 +332,5 @@ export default {
'save_stash_as_markdown': '將暫存區存為 Markdown',
'add_to_stash': '加入暫存區',
'added_to_stash': '已添加到暫存區',
'import_fail_notice': '匯入失敗',
};

View file

@ -331,4 +331,5 @@ export default {
'save_stash_as_markdown': '将暂存区保存为 Markdown',
'add_to_stash': '加入暂存区',
'added_to_stash': '已添加到暂存区',
'import_fail_notice': '导入失败',
};

View file

@ -49,11 +49,11 @@
}
@media (hover: hover) {
.ge-grid-view-container .ge-grid-container .ge-grid-item:hover {
body .ge-grid-view-container .ge-grid-container .ge-grid-item:hover {
transform: translateY(-2px);
background-color: var(--text-selection);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
border-color: var(--interactive-accent) !important;
border-color: var(--interactive-accent);
}
.ge-grid-item:hover p {
@ -3428,11 +3428,11 @@ a.ge-current-folder:hover {
}
@media (hover: hover) {
.ge-folder-button:hover {
body .ge-folder-button:hover {
background-color: var(--text-selection);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
border-color: var(--interactive-accent) !important;
border-color: var(--interactive-accent);
}
}