mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-06-01 05:30:31 +08:00
feat: add support for opening plugins
This commit is contained in:
@@ -1,14 +1,15 @@
|
||||
import React, { useRef, useEffect, useCallback } from 'react';
|
||||
import { Space, Button } from 'antd';
|
||||
import { FullscreenExitOutlined, FullscreenOutlined, CloseOutlined, MinusOutlined } from '@ant-design/icons';
|
||||
import type { AppDescriptor, AppComponentProps } from './types';
|
||||
import type { AppDescriptor, AppComponentProps, AppOpenComponentProps } from './types';
|
||||
import type { VfsEntry } from '../api/client';
|
||||
|
||||
export interface AppWindowItem {
|
||||
id: string;
|
||||
app: AppDescriptor;
|
||||
entry: VfsEntry;
|
||||
filePath: string;
|
||||
kind: 'file' | 'app';
|
||||
entry?: VfsEntry;
|
||||
filePath?: string;
|
||||
maximized: boolean;
|
||||
minimized: boolean;
|
||||
x: number;
|
||||
@@ -17,12 +18,14 @@ export interface AppWindowItem {
|
||||
height: number;
|
||||
}
|
||||
|
||||
type AppWindowPatch = Partial<Pick<AppWindowItem, 'maximized' | 'minimized' | 'x' | 'y' | 'width' | 'height'>>;
|
||||
|
||||
interface AppWindowsLayerProps {
|
||||
windows: AppWindowItem[];
|
||||
onClose: (id: string) => void;
|
||||
onToggleMax: (id: string) => void;
|
||||
onBringToFront: (id: string) => void;
|
||||
onUpdateWindow: (id: string, patch: Partial<AppWindowItem>) => void;
|
||||
onUpdateWindow: (id: string, patch: AppWindowPatch) => void;
|
||||
}
|
||||
|
||||
export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClose, onToggleMax, onBringToFront, onUpdateWindow }) => {
|
||||
@@ -193,8 +196,17 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
|
||||
return (
|
||||
<>
|
||||
{visibleWindows.map((w, idx) => {
|
||||
const AppComp = w.app.component as React.FC<AppComponentProps>;
|
||||
const isFileWindow = w.kind !== 'app';
|
||||
const FileComp = w.app.component as React.FC<AppComponentProps>;
|
||||
const OpenComp = w.app.openAppComponent as React.FC<AppOpenComponentProps> | undefined;
|
||||
const ContentComp = (isFileWindow ? FileComp : OpenComp) as React.FC<any> | undefined;
|
||||
const useSystemWindow = w.app.useSystemWindow !== false; // 默认为 true
|
||||
const titleText = isFileWindow ? `${w.app.name} - ${w.entry?.name || ''}` : w.app.name;
|
||||
|
||||
if (!ContentComp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!useSystemWindow) {
|
||||
return (
|
||||
<div
|
||||
@@ -223,16 +235,20 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
|
||||
overflow: 'hidden',
|
||||
background: 'transparent'
|
||||
}}
|
||||
>
|
||||
<AppComp
|
||||
filePath={w.filePath}
|
||||
entry={w.entry}
|
||||
onRequestClose={() => onClose(w.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
>
|
||||
{isFileWindow ? (
|
||||
<ContentComp
|
||||
filePath={w.filePath || ''}
|
||||
entry={w.entry as VfsEntry}
|
||||
onRequestClose={() => onClose(w.id)}
|
||||
/>
|
||||
) : (
|
||||
<ContentComp onRequestClose={() => onClose(w.id)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 否则继续使用系统窗口渲染(不改动原有逻辑)
|
||||
const interacting = isInteracting(w.id);
|
||||
return (
|
||||
@@ -290,9 +306,9 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
|
||||
paddingRight: 8,
|
||||
flex: 1
|
||||
}}
|
||||
>
|
||||
{w.app.name} - {w.entry.name}
|
||||
</span>
|
||||
>
|
||||
{titleText}
|
||||
</span>
|
||||
<Space size={4}>
|
||||
<Button
|
||||
type="text"
|
||||
@@ -351,11 +367,15 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
|
||||
}}
|
||||
>
|
||||
{!w.maximized && resizeHandles(w)}
|
||||
<AppComp
|
||||
filePath={w.filePath}
|
||||
entry={w.entry}
|
||||
onRequestClose={() => onClose(w.id)}
|
||||
/>
|
||||
{isFileWindow ? (
|
||||
<ContentComp
|
||||
filePath={w.filePath || ''}
|
||||
entry={w.entry as VfsEntry}
|
||||
onRequestClose={() => onClose(w.id)}
|
||||
/>
|
||||
) : (
|
||||
<ContentComp onRequestClose={() => onClose(w.id)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import type { AppComponentProps } from '../types';
|
||||
import type { AppComponentProps, AppOpenComponentProps } from '../types';
|
||||
import { vfsApi } from '../../api/vfs';
|
||||
import { loadPluginFromUrl, ensureManifest, type RegisteredPlugin } from '../../plugins/runtime';
|
||||
import { loadPlugin, ensureManifest, type RegisteredPlugin } from '../../plugins/runtime';
|
||||
import type { PluginItem } from '../../api/plugins';
|
||||
import { useAsyncSafeEffect } from '../../hooks/useAsyncSafeEffect';
|
||||
import { useI18n } from '../../i18n';
|
||||
@@ -22,7 +22,7 @@ export const PluginAppHost: React.FC<PluginAppHostProps> = ({ plugin, filePath,
|
||||
useAsyncSafeEffect(
|
||||
async ({ isDisposed }) => {
|
||||
try {
|
||||
const p = await loadPluginFromUrl(plugin.url);
|
||||
const p = await loadPlugin(plugin);
|
||||
if (isDisposed()) return;
|
||||
pluginRef.current = p;
|
||||
await ensureManifest(plugin.id, p);
|
||||
@@ -57,3 +57,52 @@ export const PluginAppHost: React.FC<PluginAppHostProps> = ({ plugin, filePath,
|
||||
|
||||
return <div ref={containerRef} style={{ width: '100%', height: '100%', overflow: 'auto' }} />;
|
||||
};
|
||||
|
||||
export interface PluginAppOpenHostProps extends AppOpenComponentProps {
|
||||
plugin: PluginItem;
|
||||
}
|
||||
|
||||
export const PluginAppOpenHost: React.FC<PluginAppOpenHostProps> = ({ plugin, onRequestClose }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const onCloseRef = useRef(onRequestClose);
|
||||
onCloseRef.current = onRequestClose;
|
||||
const { t } = useI18n();
|
||||
|
||||
const pluginRef = useRef<RegisteredPlugin | null>(null);
|
||||
|
||||
useAsyncSafeEffect(
|
||||
async ({ isDisposed }) => {
|
||||
try {
|
||||
const p = await loadPlugin(plugin);
|
||||
if (isDisposed()) return;
|
||||
pluginRef.current = p;
|
||||
await ensureManifest(plugin.id, p);
|
||||
if (isDisposed() || !containerRef.current) return;
|
||||
if (typeof p.mountApp !== 'function') {
|
||||
throw new Error('该插件不支持独立打开');
|
||||
}
|
||||
await p.mountApp(containerRef.current, {
|
||||
host: { close: () => onCloseRef.current() },
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (!isDisposed()) setError(e?.message || t('Plugin run failed'));
|
||||
}
|
||||
},
|
||||
[plugin.id, plugin.url],
|
||||
() => {
|
||||
try {
|
||||
if (!containerRef.current) return;
|
||||
const p = pluginRef.current;
|
||||
if (p?.unmountApp) return p.unmountApp(containerRef.current);
|
||||
if (p?.unmount) return p.unmount(containerRef.current);
|
||||
} catch { }
|
||||
},
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return <div style={{ padding: 12, color: 'red' }}>{t('Plugin Error')}: {error}</div>;
|
||||
}
|
||||
|
||||
return <div ref={containerRef} style={{ width: '100%', height: '100%', overflow: 'auto' }} />;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AppDescriptor } from '../types';
|
||||
import { VideoPlayerApp } from './VideoPlayer.tsx';
|
||||
import { VideoLibraryApp } from './VideoLibrary.tsx';
|
||||
|
||||
const supportedExts = ['mp4','webm','ogg','m4v','mov','mkv','avi','wmv','flv','3gp'];
|
||||
|
||||
@@ -9,6 +10,7 @@ export const descriptor: AppDescriptor = {
|
||||
iconUrl: 'https://api.iconify.design/mdi:video.svg',
|
||||
description: '内置视频播放器,支持常见视频格式播放。',
|
||||
author: 'Foxel',
|
||||
openAppComponent: VideoLibraryApp,
|
||||
supportedExts,
|
||||
supported: (entry) => {
|
||||
if (entry.is_dir) return false;
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { VfsEntry } from '../api/client';
|
||||
import type { AppDescriptor } from './types';
|
||||
import React from 'react';
|
||||
import { pluginsApi, type PluginItem } from '../api/plugins';
|
||||
import { PluginAppHost } from './PluginHost';
|
||||
import { PluginAppHost, PluginAppOpenHost } from './PluginHost';
|
||||
const apps: AppDescriptor[] = [];
|
||||
|
||||
// 使用 import.meta.glob 动态导入所有应用
|
||||
@@ -39,6 +39,7 @@ function registerPluginAsApp(p: PluginItem) {
|
||||
name: p.name || `插件 ${p.id}`,
|
||||
supported,
|
||||
component: (props: any) => React.createElement(PluginAppHost, { plugin: p, ...props }),
|
||||
openAppComponent: p.open_app ? ((props: any) => React.createElement(PluginAppOpenHost, { plugin: p, ...props })) : undefined,
|
||||
iconUrl: p.icon || undefined,
|
||||
default: false,
|
||||
defaultBounds: p.default_bounds || undefined,
|
||||
@@ -98,6 +99,9 @@ export async function reloadPluginApps() {
|
||||
existing.defaultBounds = p.default_bounds || undefined;
|
||||
existing.defaultMaximized = p.default_maximized || undefined;
|
||||
existing.iconUrl = p.icon || existing.iconUrl;
|
||||
existing.openAppComponent = p.open_app
|
||||
? ((props: any) => React.createElement(PluginAppOpenHost, { plugin: p, ...props }))
|
||||
: undefined;
|
||||
}
|
||||
});
|
||||
} catch { }
|
||||
|
||||
@@ -6,11 +6,20 @@ export interface AppComponentProps {
|
||||
onRequestClose: () => void;
|
||||
}
|
||||
|
||||
export interface AppOpenComponentProps {
|
||||
onRequestClose: () => void;
|
||||
}
|
||||
|
||||
export interface AppDescriptor {
|
||||
key: string;
|
||||
name: string;
|
||||
supported: (entry: VfsEntry) => boolean;
|
||||
component: React.ComponentType<AppComponentProps>;
|
||||
/**
|
||||
* 独立打开应用(不依赖文件)
|
||||
* 缺省表示该应用仅支持“通过文件打开”。
|
||||
*/
|
||||
openAppComponent?: React.ComponentType<AppOpenComponentProps>;
|
||||
iconUrl?: string;
|
||||
default?: boolean;
|
||||
defaultMaximized?: boolean;
|
||||
|
||||
Reference in New Issue
Block a user