mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 21:27:20 +00:00
feat: init new plugin system (#3323)
This commit is contained in:
@@ -48,8 +48,6 @@ currentWorkspaceIdAtom.onMount = set => {
|
||||
if (value) {
|
||||
set(value);
|
||||
localStorage.setItem('last_workspace_id', value);
|
||||
} else {
|
||||
set(null);
|
||||
}
|
||||
};
|
||||
callback(router.state);
|
||||
@@ -65,8 +63,6 @@ currentPageIdAtom.onMount = set => {
|
||||
const value = state.location.pathname.split('/')[3];
|
||||
if (value) {
|
||||
set(value);
|
||||
} else {
|
||||
set(null);
|
||||
}
|
||||
};
|
||||
callback(router.state);
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import type { ExpectedLayout } from '@toeverything/plugin-infra/type';
|
||||
import { atom } from 'jotai';
|
||||
|
||||
export const contentLayoutBaseAtom = atom<ExpectedLayout>('editor');
|
||||
|
||||
type SetStateAction<Value> = Value | ((prev: Value) => Value);
|
||||
export const contentLayoutAtom = atom<
|
||||
ExpectedLayout,
|
||||
[SetStateAction<ExpectedLayout>],
|
||||
void
|
||||
>(
|
||||
get => get(contentLayoutBaseAtom),
|
||||
(get, set, layout) => {
|
||||
set(contentLayoutBaseAtom, prev => {
|
||||
let setV: (prev: ExpectedLayout) => ExpectedLayout;
|
||||
if (typeof layout !== 'function') {
|
||||
setV = () => layout;
|
||||
} else {
|
||||
setV = layout;
|
||||
}
|
||||
const nextValue = setV(prev);
|
||||
if (nextValue === 'editor') {
|
||||
return nextValue;
|
||||
}
|
||||
if (nextValue.first !== 'editor') {
|
||||
throw new Error('The first element of the layout should be editor.');
|
||||
}
|
||||
if (nextValue.splitPercentage && nextValue.splitPercentage < 70) {
|
||||
throw new Error('The split percentage should be greater than 70.');
|
||||
}
|
||||
return nextValue;
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -1,6 +1,181 @@
|
||||
import('@affine/bookmark-block');
|
||||
if (runtimeConfig.enablePlugin) {
|
||||
import('@affine/copilot');
|
||||
}
|
||||
/// <reference types="@types/webpack-env" />
|
||||
import 'ses';
|
||||
|
||||
import * as AFFiNEComponent from '@affine/component';
|
||||
import * as BlockSuiteBlocksStd from '@blocksuite/blocks/std';
|
||||
import { DisposableGroup } from '@blocksuite/global/utils';
|
||||
import * as BlockSuiteGlobalUtils from '@blocksuite/global/utils';
|
||||
import * as Icons from '@blocksuite/icons';
|
||||
import type {
|
||||
CallbackMap,
|
||||
PluginContext,
|
||||
} from '@toeverything/plugin-infra/entry';
|
||||
import * as Manager from '@toeverything/plugin-infra/manager';
|
||||
import {
|
||||
editorItemsAtom,
|
||||
headerItemsAtom,
|
||||
registeredPluginAtom,
|
||||
rootStore,
|
||||
windowItemsAtom,
|
||||
} from '@toeverything/plugin-infra/manager';
|
||||
import * as Jotai from 'jotai';
|
||||
import { Provider } from 'jotai/react';
|
||||
import * as JotaiUtils from 'jotai/utils';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import * as React from 'react';
|
||||
import * as ReactJSXRuntime from 'react/jsx-runtime';
|
||||
import * as ReactDom from 'react-dom';
|
||||
import * as ReactDomClient from 'react-dom/client';
|
||||
|
||||
const PluginProvider = ({ children }: PropsWithChildren) =>
|
||||
React.createElement(
|
||||
Provider,
|
||||
{
|
||||
store: rootStore,
|
||||
},
|
||||
children
|
||||
);
|
||||
|
||||
console.log('JotaiUtils', JotaiUtils);
|
||||
|
||||
const customRequire = (id: string) => {
|
||||
if (id === '@toeverything/plugin-infra/manager') {
|
||||
return harden(Manager);
|
||||
}
|
||||
if (id === 'react') {
|
||||
return React;
|
||||
}
|
||||
if (id === 'react/jsx-runtime') {
|
||||
return ReactJSXRuntime;
|
||||
}
|
||||
if (id === 'react-dom') {
|
||||
return ReactDom;
|
||||
}
|
||||
if (id === 'react-dom/client') {
|
||||
return ReactDomClient;
|
||||
}
|
||||
if (id === '@blocksuite/icons') {
|
||||
return harden(Icons);
|
||||
}
|
||||
if (id === '@affine/component') {
|
||||
return harden(AFFiNEComponent);
|
||||
}
|
||||
if (id === '@blocksuite/blocks/std') {
|
||||
return harden(BlockSuiteBlocksStd);
|
||||
}
|
||||
if (id === '@blocksuite/global/utils') {
|
||||
return harden(BlockSuiteGlobalUtils);
|
||||
}
|
||||
if (id === 'jotai') {
|
||||
return harden(Jotai);
|
||||
}
|
||||
if (id === 'jotai/utils') {
|
||||
return harden(JotaiUtils);
|
||||
}
|
||||
if (id === '../plugin.js') {
|
||||
return entryCompartment.evaluate('exports');
|
||||
}
|
||||
throw new Error(`Cannot find module '${id}'`);
|
||||
};
|
||||
|
||||
const createGlobalThis = () => {
|
||||
return {
|
||||
process: harden({
|
||||
env: {
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
},
|
||||
}),
|
||||
// UNSAFE: React will read `window` and `document`
|
||||
window,
|
||||
document,
|
||||
navigator,
|
||||
userAgent: navigator.userAgent,
|
||||
|
||||
// fixme: use our own db api
|
||||
indexedDB: globalThis.indexedDB,
|
||||
IDBRequest: globalThis.IDBRequest,
|
||||
IDBDatabase: globalThis.IDBDatabase,
|
||||
IDBCursorWithValue: globalThis.IDBCursorWithValue,
|
||||
IDBFactory: globalThis.IDBFactory,
|
||||
IDBKeyRange: globalThis.IDBKeyRange,
|
||||
IDBOpenDBRequest: globalThis.IDBOpenDBRequest,
|
||||
IDBTransaction: globalThis.IDBTransaction,
|
||||
IDBObjectStore: globalThis.IDBObjectStore,
|
||||
IDBIndex: globalThis.IDBIndex,
|
||||
IDBCursor: globalThis.IDBCursor,
|
||||
IDBVersionChangeEvent: globalThis.IDBVersionChangeEvent,
|
||||
|
||||
exports: {},
|
||||
console: globalThis.console,
|
||||
require: customRequire,
|
||||
};
|
||||
};
|
||||
|
||||
const group = new DisposableGroup();
|
||||
const pluginList = await (
|
||||
await fetch(new URL(`./plugins/plugin-list.json`, window.location.origin))
|
||||
).json();
|
||||
const builtInPlugins: string[] = pluginList.map((plugin: any) => plugin.name);
|
||||
const pluginGlobalThis = createGlobalThis();
|
||||
const pluginEntry = await fetch('/plugins/plugin.js').then(res => res.text());
|
||||
const entryCompartment = new Compartment(pluginGlobalThis, {});
|
||||
entryCompartment.evaluate(pluginEntry, {
|
||||
__evadeHtmlCommentTest__: true,
|
||||
});
|
||||
await Promise.all(
|
||||
builtInPlugins.map(plugin => {
|
||||
const pluginCompartment = new Compartment(createGlobalThis(), {});
|
||||
const pluginGlobalThis = pluginCompartment.globalThis;
|
||||
const baseURL = new URL(`./plugins/${plugin}/`, window.location.origin);
|
||||
const packageJsonURL = new URL('package.json', baseURL);
|
||||
return fetch(packageJsonURL).then(async res => {
|
||||
const packageJson = await res.json();
|
||||
const pluginConfig = packageJson['affinePlugin'];
|
||||
if (
|
||||
pluginConfig.release === false &&
|
||||
process.env.NODE_ENV !== 'development'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
rootStore.set(registeredPluginAtom, prev => [...prev, plugin]);
|
||||
const coreEntry = new URL(pluginConfig.entry.core, baseURL.toString());
|
||||
const codeText = await fetch(coreEntry).then(res => res.text());
|
||||
pluginCompartment.evaluate(codeText);
|
||||
pluginGlobalThis.__INTERNAL__ENTRY = {
|
||||
register: (part, callback) => {
|
||||
if (part === 'headerItem') {
|
||||
rootStore.set(headerItemsAtom, items => ({
|
||||
...items,
|
||||
[plugin]: callback as CallbackMap['headerItem'],
|
||||
}));
|
||||
} else if (part === 'editor') {
|
||||
rootStore.set(editorItemsAtom, items => ({
|
||||
...items,
|
||||
[plugin]: callback as CallbackMap['editor'],
|
||||
}));
|
||||
} else if (part === 'window') {
|
||||
rootStore.set(windowItemsAtom, items => ({
|
||||
...items,
|
||||
[plugin]: callback as CallbackMap['window'],
|
||||
}));
|
||||
} else {
|
||||
throw new Error(`Unknown part: ${part}`);
|
||||
}
|
||||
},
|
||||
utils: {
|
||||
PluginProvider,
|
||||
},
|
||||
} satisfies PluginContext;
|
||||
const dispose = pluginCompartment.evaluate(
|
||||
'exports.entry(__INTERNAL__ENTRY)'
|
||||
);
|
||||
if (typeof dispose !== 'function') {
|
||||
throw new Error('Plugin entry must return a function');
|
||||
}
|
||||
pluginGlobalThis.__INTERNAL__ENTRY = undefined;
|
||||
group.add(dispose);
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
console.log('register plugins finished');
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
import { SettingHeader } from '@affine/component/setting-components';
|
||||
import {
|
||||
SettingHeader,
|
||||
SettingWrapper,
|
||||
} from '@affine/component/setting-components';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { affinePluginsAtom } from '@toeverything/plugin-infra/manager';
|
||||
import { registeredPluginAtom } from '@toeverything/plugin-infra/manager';
|
||||
import { useAtomValue } from 'jotai';
|
||||
|
||||
export const Plugins = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
const plugins = useAtomValue(affinePluginsAtom);
|
||||
const allowedPlugins = useAtomValue(registeredPluginAtom);
|
||||
console.log('allowedPlugins', allowedPlugins);
|
||||
return (
|
||||
<>
|
||||
<SettingHeader
|
||||
title={'Plugins'}
|
||||
subtitle={t['None yet']()}
|
||||
subtitle={allowedPlugins.length === 0 && t['None yet']()}
|
||||
data-testid="plugins-title"
|
||||
/>
|
||||
{Object.values(plugins).map(({ definition, uiAdapter }) => {
|
||||
const Content = uiAdapter.debugContent;
|
||||
return <div key={definition.id}>{Content && <Content />}</div>;
|
||||
})}
|
||||
{allowedPlugins.map(plugin => (
|
||||
<SettingWrapper key={plugin} title={plugin}></SettingWrapper>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,20 +7,18 @@ import { SidebarSwitch } from '@affine/component/app-sidebar/sidebar-header';
|
||||
import { isDesktop } from '@affine/env/constant';
|
||||
import { CloseIcon, MinusIcon, RoundedRectangleIcon } from '@blocksuite/icons';
|
||||
import type { Page } from '@blocksuite/store';
|
||||
import { affinePluginsAtom } from '@toeverything/plugin-infra/manager';
|
||||
import type { PluginUIAdapter } from '@toeverything/plugin-infra/type';
|
||||
import { headerItemsAtom } from '@toeverything/plugin-infra/manager';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import type { FC, HTMLAttributes, PropsWithChildren, ReactNode } from 'react';
|
||||
import {
|
||||
forwardRef,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { contentLayoutAtom } from '../../../atoms/layout';
|
||||
import { currentModeAtom } from '../../../atoms/mode';
|
||||
import type { AffineOfficialWorkspace } from '../../../shared';
|
||||
import DownloadClientTip from './download-tips';
|
||||
@@ -124,41 +122,37 @@ const HeaderRightItems: Record<HeaderRightItemName, HeaderItem> = {
|
||||
|
||||
export type HeaderProps = BaseHeaderProps;
|
||||
|
||||
const PluginHeaderItemAdapter = memo<{
|
||||
headerItem: PluginUIAdapter['headerItem'];
|
||||
}>(function PluginHeaderItemAdapter({ headerItem }) {
|
||||
return (
|
||||
<div>
|
||||
{headerItem({
|
||||
contentLayoutAtom,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const PluginHeader = () => {
|
||||
const affinePluginsMap = useAtomValue(affinePluginsAtom);
|
||||
const plugins = useMemo(
|
||||
() => Object.values(affinePluginsMap),
|
||||
[affinePluginsMap]
|
||||
);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const headerItems = useAtomValue(headerItemsAtom);
|
||||
useEffect(() => {
|
||||
const root = rootRef.current;
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
let disposes: (() => void)[] = [];
|
||||
const renderTimeout = setTimeout(() => {
|
||||
disposes = Object.entries(headerItems).map(([id, headerItem]) => {
|
||||
const div = document.createElement('div');
|
||||
div.setAttribute('plugin-id', id);
|
||||
const cleanup = headerItem(div);
|
||||
root.appendChild(div);
|
||||
return () => {
|
||||
cleanup();
|
||||
root.removeChild(div);
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{plugins
|
||||
.filter(plugin => plugin.uiAdapter.headerItem != null)
|
||||
.map(plugin => {
|
||||
const headerItem = plugin.uiAdapter
|
||||
.headerItem as PluginUIAdapter['headerItem'];
|
||||
return (
|
||||
<PluginHeaderItemAdapter
|
||||
key={plugin.definition.id}
|
||||
headerItem={headerItem}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
return () => {
|
||||
clearTimeout(renderTimeout);
|
||||
setTimeout(() => {
|
||||
disposes.forEach(dispose => dispose());
|
||||
});
|
||||
};
|
||||
}, [headerItems]);
|
||||
|
||||
return <div className={styles.pluginHeaderItems} ref={rootRef} />;
|
||||
};
|
||||
|
||||
export const Header = forwardRef<
|
||||
|
||||
@@ -238,3 +238,10 @@ export const windowAppControl = style({
|
||||
},
|
||||
},
|
||||
} as ComplexStyleRule);
|
||||
|
||||
export const pluginHeaderItems = style({
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
});
|
||||
|
||||
@@ -7,21 +7,22 @@ import { assertExists } from '@blocksuite/global/utils';
|
||||
import type { Page, Workspace } from '@blocksuite/store';
|
||||
import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta';
|
||||
import { useBlockSuiteWorkspacePage } from '@toeverything/hooks/use-block-suite-workspace-page';
|
||||
import { affinePluginsAtom } from '@toeverything/plugin-infra/manager';
|
||||
import type {
|
||||
AffinePlugin,
|
||||
LayoutNode,
|
||||
PluginUIAdapter,
|
||||
} from '@toeverything/plugin-infra/type';
|
||||
import type { PluginBlockSuiteAdapter } from '@toeverything/plugin-infra/type';
|
||||
import type { CallbackMap } from '@toeverything/plugin-infra/entry';
|
||||
import {
|
||||
affinePluginsAtom,
|
||||
contentLayoutAtom,
|
||||
editorItemsAtom,
|
||||
rootStore,
|
||||
windowItemsAtom,
|
||||
} from '@toeverything/plugin-infra/manager';
|
||||
import type { AffinePlugin, LayoutNode } from '@toeverything/plugin-infra/type';
|
||||
import clsx from 'clsx';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import type { CSSProperties, FC, ReactElement } from 'react';
|
||||
import { memo, Suspense, useCallback, useMemo } from 'react';
|
||||
import { memo, Suspense, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
|
||||
|
||||
import { pageSettingFamily } from '../atoms';
|
||||
import { contentLayoutAtom } from '../atoms/layout';
|
||||
import { fontStyleOptions, useAppSetting } from '../atoms/settings';
|
||||
import { BlockSuiteEditor as Editor } from './blocksuite/block-suite-editor';
|
||||
import * as styles from './page-detail-editor.css';
|
||||
@@ -42,11 +43,6 @@ const EditorWrapper = memo(function EditorWrapper({
|
||||
onLoad,
|
||||
isPublic,
|
||||
}: PageDetailEditorProps) {
|
||||
const affinePluginsMap = useAtomValue(affinePluginsAtom);
|
||||
const plugins = useMemo(
|
||||
() => Object.values(affinePluginsMap),
|
||||
[affinePluginsMap]
|
||||
);
|
||||
const page = useBlockSuiteWorkspacePage(workspace, pageId);
|
||||
if (!page) {
|
||||
throw new PageNotFoundError(workspace, pageId);
|
||||
@@ -100,33 +96,65 @@ const EditorWrapper = memo(function EditorWrapper({
|
||||
if (onLoad) {
|
||||
dispose = onLoad(page, editor);
|
||||
}
|
||||
const uiDecorators = plugins
|
||||
.map(plugin => plugin.blockSuiteAdapter.uiDecorator)
|
||||
.filter((ui): ui is PluginBlockSuiteAdapter['uiDecorator'] =>
|
||||
Boolean(ui)
|
||||
);
|
||||
const disposes = uiDecorators.map(ui => ui(editor));
|
||||
const editorItems = rootStore.get(editorItemsAtom);
|
||||
let disposes: (() => void)[] = [];
|
||||
const renderTimeout = setTimeout(() => {
|
||||
disposes = Object.entries(editorItems).map(([id, editorItem]) => {
|
||||
const div = document.createElement('div');
|
||||
div.setAttribute('plugin-id', id);
|
||||
const cleanup = editorItem(div, editor);
|
||||
assertExists(parent);
|
||||
document.body.appendChild(div);
|
||||
return () => {
|
||||
cleanup();
|
||||
document.body.removeChild(div);
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposes.forEach(fn => fn());
|
||||
dispose();
|
||||
clearTimeout(renderTimeout);
|
||||
setTimeout(() => {
|
||||
disposes.forEach(dispose => dispose());
|
||||
});
|
||||
};
|
||||
},
|
||||
[plugins, onLoad]
|
||||
[onLoad]
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const PluginContentAdapter = memo<{
|
||||
detailContent: PluginUIAdapter['detailContent'];
|
||||
}>(function PluginContentAdapter({ detailContent }) {
|
||||
return (
|
||||
<div className={pluginContainer}>
|
||||
{detailContent({
|
||||
contentLayoutAtom,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
windowItem: CallbackMap['window'];
|
||||
}>(function PluginContentAdapter({ windowItem }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const root = ref.current;
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
let cleanup: () => void = () => {};
|
||||
let childDiv: HTMLDivElement | null = null;
|
||||
const renderTimeout = setTimeout(() => {
|
||||
const div = document.createElement('div');
|
||||
cleanup = windowItem(div);
|
||||
root.appendChild(div);
|
||||
childDiv = div;
|
||||
});
|
||||
|
||||
return () => {
|
||||
clearTimeout(renderTimeout);
|
||||
setTimeout(() => {
|
||||
cleanup();
|
||||
if (childDiv) {
|
||||
root.removeChild(childDiv);
|
||||
}
|
||||
});
|
||||
};
|
||||
}, [windowItem]);
|
||||
return <div className={pluginContainer} ref={ref} />;
|
||||
});
|
||||
|
||||
type LayoutPanelProps = {
|
||||
@@ -139,16 +167,13 @@ const LayoutPanel = memo(function LayoutPanel(
|
||||
props: LayoutPanelProps
|
||||
): ReactElement {
|
||||
const node = props.node;
|
||||
const windowItems = useAtomValue(windowItemsAtom);
|
||||
if (typeof node === 'string') {
|
||||
if (node === 'editor') {
|
||||
return <EditorWrapper {...props.editorProps} />;
|
||||
} else {
|
||||
const plugin = props.plugins.find(
|
||||
plugin => plugin.definition.id === node
|
||||
);
|
||||
const Content = plugin?.uiAdapter.detailContent;
|
||||
assertExists(Content);
|
||||
return <PluginContentAdapter detailContent={Content} />;
|
||||
const windowItem = windowItems[node];
|
||||
return <PluginContentAdapter windowItem={windowItem} />;
|
||||
}
|
||||
} else {
|
||||
return (
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { EditorContainer } from '@blocksuite/editor';
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
import type { Page } from '@blocksuite/store';
|
||||
import { currentPageIdAtom } from '@toeverything/plugin-infra/manager';
|
||||
import { useAtomValue } from 'jotai/index';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useAtom } from 'jotai/react';
|
||||
import { type ReactElement, useCallback, useEffect } from 'react';
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
Reference in New Issue
Block a user