devon22_obsidian-gridexplorer/src/modal/mediaModal.ts
2026-06-08 01:56:16 +08:00

563 lines
20 KiB
TypeScript

import { App, Modal, TFile, Menu, setIcon, Platform } from 'obsidian';
import { isImageFile, isVideoFile, isAudioFile } from '../utils/fileUtils';
export interface VirtualMediaFile {
name: string;
path: string;
isVirtual: true;
getBlobUrl: () => Promise<string>;
}
export type MediaFile = TFile | VirtualMediaFile;
export function isImage(file: MediaFile): boolean {
if ('isVirtual' in file && file.isVirtual) {
const ext = file.name.split('.').pop()?.toLowerCase() || '';
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'bmp', 'svg'].includes(ext);
}
if (file instanceof TFile) {
return isImageFile(file);
}
return false;
}
export function isVideo(file: MediaFile): boolean {
if ('isVirtual' in file && file.isVirtual) {
const ext = file.name.split('.').pop()?.toLowerCase() || '';
return ['mp4', 'webm', 'mov', 'avi', 'mkv', 'ogv'].includes(ext);
}
if (file instanceof TFile) {
return isVideoFile(file);
}
return false;
}
export function isAudio(file: MediaFile): boolean {
if ('isVirtual' in file && file.isVirtual) {
const ext = file.name.split('.').pop()?.toLowerCase() || '';
return ['flac', 'm4a', 'mp3', 'ogg', 'wav', '3gp'].includes(ext);
}
if (file instanceof TFile) {
return isAudioFile(file);
}
return false;
}
interface GridViewFocusTarget {
gridItems: HTMLElement[];
hasKeyboardFocus: boolean;
selectItem(index: number): void;
}
export class MediaModal extends Modal {
private file: MediaFile;
private mediaFiles: MediaFile[];
private currentIndex: number;
private currentMediaElement: HTMLElement | null = null;
private isZoomed = false;
private handleWheel: EventListener | null = null;
private gridView?: GridViewFocusTarget; // 儲存 GridView 實例的引用
// 觸控拖曳相關屬性
private touchStartX = 0;
private touchStartY = 0;
private touchStartTime = 0;
private isDragging = false;
private minSwipeDistance = 50; // 最小滑動距離
private maxSwipeTime = 300; // 最大滑動時間(毫秒)
constructor(app: App, file: MediaFile, mediaFiles: MediaFile[], gridView?: GridViewFocusTarget) {
super(app);
this.file = file;
this.mediaFiles = mediaFiles;
this.currentIndex = this.mediaFiles.findIndex(f => f.path === file.path);
this.gridView = gridView; // 保存 GridView 實例
// 設置 modal 樣式
this.modalEl.addClass('ge-media-modal');
}
onOpen() {
const { contentEl } = this;
// 設置 modal 樣式為全螢幕
contentEl.empty();
contentEl.addClass('ge-media-modal-content');
// 創建媒體顯示區域
const mediaView = contentEl.createDiv('ge-media-view');
// 創建關閉按鈕
const closeButton = contentEl.createDiv('ge-media-close-button');
setIcon(closeButton, 'x');
closeButton.addEventListener('click', (e) => {
e.stopPropagation();
this.close();
});
// 創建左右切換按鈕區域
const prevArea = contentEl.createDiv('ge-media-prev-area');
const nextArea = contentEl.createDiv('ge-media-next-area');
// 創建媒體元素容器
const mediaContainer = mediaView.createDiv('ge-media-container');
// 點擊背景關閉媒體檢視器
mediaContainer.addEventListener('click', (e) => {
// 確保點擊的是背景,而不是媒體內容或其他控制元素
if (e.target === mediaContainer) {
this.close();
}
});
// 註冊左右區域點擊事件
prevArea.addEventListener('click', (e) => {
e.stopPropagation();
this.showPrevMedia();
});
nextArea.addEventListener('click', (e) => {
e.stopPropagation();
this.showNextMedia();
});
// 註冊滑鼠滾輪事件
contentEl.addEventListener('wheel', (e) => {
// 只有在非縮放狀態下才使用滾輪切換圖片
if (!this.isZoomed) {
e.preventDefault();
if (e.deltaY > 0) {
this.showNextMedia();
} else {
this.showPrevMedia();
}
}
});
// 註冊鍵盤快捷鍵
this.scope.register(null, 'ArrowLeft', () => {
this.showPrevMedia();
return false;
});
this.scope.register(null, 'ArrowRight', () => {
this.showNextMedia();
return false;
});
this.scope.register(null, 'Home', () => {
this.showMediaAtIndex(0);
return false;
});
this.scope.register(null, 'End', () => {
this.showMediaAtIndex(this.mediaFiles.length - 1);
return false;
});
this.scope.register(null, 'PageUp', () => {
const prevIndex = Math.max(0, this.currentIndex - 5);
this.showMediaAtIndex(prevIndex);
return false;
});
this.scope.register(null, 'PageDown', () => {
const nextIndex = Math.min(this.mediaFiles.length - 1, this.currentIndex + 5);
this.showMediaAtIndex(nextIndex);
return false;
});
// 註冊觸控事件(行動裝置拖曳翻頁)
this.registerTouchEvents(this.contentEl);
// 註冊右鍵選單事件
this.contentEl.addEventListener('contextmenu', (e) => {
const currentFile = this.mediaFiles[this.currentIndex];
if (currentFile) {
this.onMediaContextMenu(e, currentFile);
}
});
// 顯示當前媒體檔案
this.showMediaAtIndex(this.currentIndex);
}
onClose() {
const { contentEl } = this;
contentEl.empty();
// 如果存在之前的滾輪事件處理程序,先移除它
if (this.handleWheel) {
const mediaView = contentEl.querySelector<HTMLElement>('.ge-media-view');
if (mediaView) {
mediaView.removeEventListener('wheel', this.handleWheel);
}
this.handleWheel = null;
}
// 如果有 GridView 實例,跳轉到當前選中的項目
if (this.gridView) {
// 找到當前媒體檔案在 GridView 中的索引
const currentFile = this.mediaFiles[this.currentIndex];
if (!currentFile) return;
const gridItemIndex = this.gridView.gridItems.findIndex((item: HTMLElement) =>
item.dataset.filePath === currentFile.path
);
// 如果找到了對應的項目,選中它並設置鍵盤焦點
if (gridItemIndex >= 0) {
this.gridView.hasKeyboardFocus = true;
this.gridView.selectItem(gridItemIndex);
}
}
}
// 顯示指定索引的媒體檔案
showMediaAtIndex(index: number) {
if (index < 0 || index >= this.mediaFiles.length) return;
const { contentEl } = this;
const mediaContainer = contentEl.querySelector('.ge-media-container');
if (!mediaContainer) return;
// 更新當前顯示的索引
this.currentIndex = index;
// 如果存在之前的滾輪事件處理程序,先移除它
if (this.handleWheel) {
const mediaView = contentEl.querySelector('.ge-media-view');
if (mediaView) {
mediaView.removeEventListener('wheel', this.handleWheel);
}
this.handleWheel = null;
}
this.isZoomed = false;
this.contentEl.removeClass('is-zoomed');
const mediaFile = this.mediaFiles[index];
if (isImage(mediaFile)) {
// 創建圖片元素
const img = activeDocument.createElement('img');
img.className = 'ge-fullscreen-image';
img.addClass('ge-hidden'); // 先隱藏新圖片
// 等待新圖片載入完成
img.onload = () => {
// 移除舊的媒體元素
if (this.currentMediaElement) {
this.currentMediaElement.remove();
}
this.currentMediaElement = img;
// 設置圖片樣式,預設滿屏顯示
this.resetImageStyles(img);
// 顯示新圖片
img.removeClass('ge-hidden');
};
if ('isVirtual' in mediaFile && mediaFile.isVirtual) {
mediaFile.getBlobUrl().then(url => {
img.src = url;
}).catch(err => console.error("Error loading virtual image:", err));
} else if (mediaFile instanceof TFile) {
img.src = this.app.vault.getResourcePath(mediaFile);
}
mediaContainer.appendChild(img);
// 圖片點擊事件(放大/縮小)
if (Platform.isMobile) {
img.addEventListener('dblclick', (event) => {
event.stopPropagation();
this.toggleImageZoom(img, event);
});
} else {
img.addEventListener('click', (event) => {
event.stopPropagation();
this.toggleImageZoom(img, event);
});
}
} else if (isVideo(mediaFile) || isAudio(mediaFile)) {
// 對於影片和音樂,維持原有的處理方式
if (this.currentMediaElement) {
this.currentMediaElement.remove();
}
const video = activeDocument.createElement('video');
video.className = 'ge-fullscreen-video';
video.controls = true;
video.autoplay = true;
if ('isVirtual' in mediaFile && mediaFile.isVirtual) {
mediaFile.getBlobUrl().then(url => {
video.src = url;
}).catch(err => console.error("Error loading virtual video/audio:", err));
} else if (mediaFile instanceof TFile) {
video.src = this.app.vault.getResourcePath(mediaFile);
}
mediaContainer.appendChild(video);
this.currentMediaElement = video;
}
const oldFileNameElement = mediaContainer.querySelector('.ge-fullscreen-file-name');
if (oldFileNameElement) {
oldFileNameElement.remove();
}
if (isAudio(mediaFile)) {
//顯示檔案名稱
const fileName = mediaFile.name;
const fileNameElement = activeDocument.createElement('div');
fileNameElement.className = 'ge-fullscreen-file-name';
fileNameElement.textContent = fileName;
mediaContainer.appendChild(fileNameElement);
}
}
// 顯示下一個媒體檔案
showNextMedia() {
const nextIndex = (this.currentIndex + 1) % this.mediaFiles.length;
this.showMediaAtIndex(nextIndex);
}
// 顯示上一個媒體檔案
showPrevMedia() {
const prevIndex = (this.currentIndex - 1 + this.mediaFiles.length) % this.mediaFiles.length;
this.showMediaAtIndex(prevIndex);
}
// 重設圖片樣式
resetImageStyles(img: HTMLImageElement) {
const mediaView = this.contentEl.querySelector<HTMLElement>('.ge-media-view');
if (!mediaView) return;
img.setCssStyles({
width: 'auto',
height: 'auto',
maxWidth: '100vw',
maxHeight: '100vh',
position: 'absolute',
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
cursor: 'zoom-in',
});
mediaView.setCssStyles({
overflowX: 'hidden',
overflowY: 'hidden',
});
// 等待圖片載入完成後調整大小
img.onload = () => {
if (mediaView.clientWidth > mediaView.clientHeight) {
if (img.naturalHeight < mediaView.clientHeight) {
img.setCssStyles({ height: '100%' });
}
} else {
if (img.naturalWidth < mediaView.clientWidth) {
img.setCssStyles({ width: '100%' });
}
}
};
// 如果圖片已經載入,立即調整大小
if (img.complete) {
if (mediaView.clientWidth > mediaView.clientHeight) {
if (img.naturalHeight < mediaView.clientHeight) {
img.setCssStyles({ height: '100%' });
}
} else {
if (img.naturalWidth < mediaView.clientWidth) {
img.setCssStyles({ width: '100%' });
}
}
}
}
// 切換圖片縮放
toggleImageZoom(img: HTMLImageElement, event?: MouseEvent) {
const mediaView = this.contentEl.querySelector<HTMLElement>('.ge-media-view');
if (!mediaView) return;
if (!this.isZoomed) { // 放大
// 保存點擊位置相對於圖片的比例
let clickX = 0.5;
let clickY = 0.5;
if (event) {
const rect = img.getBoundingClientRect();
clickX = (event.clientX - rect.left) / rect.width;
clickY = (event.clientY - rect.top) / rect.height;
}
// 根據圖片與視窗的長寬比來決定放大模式
const imageAspect = img.naturalWidth / img.naturalHeight;
const screenAspect = mediaView.clientWidth / mediaView.clientHeight;
// 如果圖片比視窗更"細長" (Aspect Ratio 較小),則寬度填滿 (Fit Width),垂直捲動
// 如果圖片比視窗更"扁平" (Aspect Ratio 較大),則高度填滿 (Fit Height),水平捲動
if (imageAspect < screenAspect) {
img.setCssStyles({
width: '100vw',
height: 'auto',
});
mediaView.setCssStyles({
overflowX: 'hidden',
overflowY: 'scroll',
});
// 計算滾動位置
window.requestAnimationFrame(() => {
const newHeight = img.offsetHeight;
const scrollY = Math.max(0, (newHeight * clickY) - (mediaView.clientHeight / 2));
mediaView.scrollTop = scrollY;
});
} else {
img.setCssStyles({
width: 'auto',
height: '100vh',
});
mediaView.setCssStyles({
overflowX: 'scroll',
overflowY: 'hidden',
});
// 計算滾動位置
window.requestAnimationFrame(() => {
const newWidth = img.offsetWidth;
const scrollX = Math.max(0, (newWidth * clickX) - (mediaView.clientWidth / 2));
mediaView.scrollLeft = scrollX;
});
// 將事件處理程序存儲在變數中
this.handleWheel = (event) => {
const wheelEvent = event as WheelEvent;
wheelEvent.preventDefault();
mediaView.scrollLeft += wheelEvent.deltaY;
};
mediaView.addEventListener('wheel', this.handleWheel);
}
img.setCssStyles({
maxWidth: 'none',
maxHeight: 'none',
position: 'relative',
left: '0',
top: '0',
margin: 'auto',
transform: 'none',
cursor: 'zoom-out',
});
this.isZoomed = true;
this.contentEl.addClass('is-zoomed');
} else { // 縮小
// 如果存在之前的滾輪事件處理程序,先移除它
if (this.handleWheel) {
mediaView.removeEventListener('wheel', this.handleWheel);
this.handleWheel = null;
}
this.resetImageStyles(img);
this.isZoomed = false;
this.contentEl.removeClass('is-zoomed');
}
}
// 處理媒體右鍵選單
private onMediaContextMenu(event: MouseEvent, file: MediaFile) {
if ('isVirtual' in file && file.isVirtual) {
return;
}
event.preventDefault();
const menu = new Menu();
if (file instanceof TFile) {
this.app.workspace.trigger('file-menu', menu, file, 'media-viewer');
}
menu.showAtMouseEvent(event);
}
// 註冊觸控事件處理器(行動裝置拖曳翻頁)
private registerTouchEvents(element: HTMLElement) {
element.addEventListener('touchstart', (e) => {
// 只有在非縮放狀態下才處理觸控事件
if (this.isZoomed) return;
// 排除控制項相關元素,避免干擾原生點擊功能
const target = e.target as HTMLElement;
if (target.closest('.ge-media-close-button')) {
this.touchStartX = -1; // 標記為忽略
return;
}
const touch = e.touches[0];
this.touchStartX = touch.clientX;
this.touchStartY = touch.clientY;
this.touchStartTime = Date.now();
this.isDragging = false;
}, { passive: true });
element.addEventListener('touchmove', (e) => {
// 只有在非縮放狀態下且非忽略目標時才處理觸控事件
if (this.isZoomed || this.touchStartX === -1) return;
const touch = e.touches[0];
const deltaX = Math.abs(touch.clientX - this.touchStartX);
const deltaY = Math.abs(touch.clientY - this.touchStartY);
// 如果水平移動距離大於垂直移動距離,則認為是水平拖曳
// 如果垂直移動距離大於水平移動距離,則認為是垂直拖曳
if ((deltaX > deltaY && deltaX > 10) || (deltaY > deltaX && deltaY > 10)) {
this.isDragging = true;
// 如果是在影片/音訊上,只有在明顯滑動時才阻止預設行為(避免干擾控制條點擊)
const target = e.target as HTMLElement;
if (target.tagName === 'VIDEO' || target.tagName === 'AUDIO') {
if (deltaX > 20 || deltaY > 20) {
e.preventDefault();
}
} else {
e.preventDefault();
}
}
}, { passive: false });
element.addEventListener('touchend', (e) => {
// 只有在非縮放狀態下且非忽略目標時才處理觸控事件
if (this.isZoomed || this.touchStartX === -1) return;
if (!this.isDragging) return;
const touch = e.changedTouches[0];
const deltaX = touch.clientX - this.touchStartX;
const deltaY = touch.clientY - this.touchStartY;
const deltaTime = Date.now() - this.touchStartTime;
// 檢查是否符合滑動條件
const isHorizontalSwipe = Math.abs(deltaX) > Math.abs(deltaY);
const isVerticalSwipe = Math.abs(deltaY) > Math.abs(deltaX);
const isValidDistance = Math.abs(deltaX) >= this.minSwipeDistance || Math.abs(deltaY) >= this.minSwipeDistance;
const isValidTime = deltaTime <= this.maxSwipeTime;
if (isValidDistance && isValidTime) {
if (isHorizontalSwipe) {
if (deltaX > 0) {
// 向右滑動 - 顯示上一個媒體
this.showPrevMedia();
} else {
// 向左滑動 - 顯示下一個媒體
this.showNextMedia();
}
} else if (isVerticalSwipe && deltaY > 0) {
// 向下滑動 - 關閉全螢幕
this.close();
}
}
// 重置拖曳狀態
this.isDragging = false;
}, { passive: true });
}
}