feat: support enable/disable plugin (#3605)

This commit is contained in:
Alex Yang
2023-08-07 20:58:31 -04:00
committed by GitHub
parent ec05bd3f53
commit b147624f1c
14 changed files with 434 additions and 209 deletions

View File

@@ -1,5 +1,5 @@
import { assertExists } from '@blocksuite/global/utils';
import { registeredPluginAtom, rootStore } from '@toeverything/infra/atom';
import { loadedPluginNameAtom, rootStore } from '@toeverything/infra/atom';
import { use } from 'foxact/use';
import { useAtomValue } from 'jotai';
import { Provider } from 'jotai/react';
@@ -17,7 +17,7 @@ async function main() {
const App = () => {
use(pluginRegisterPromise);
const plugins = useAtomValue(registeredPluginAtom);
const plugins = useAtomValue(loadedPluginNameAtom);
_pluginNestedImportsMap.forEach(value => {
const exports = value.get('index.js');
assertExists(exports);

View File

@@ -1,18 +1,26 @@
import { DebugLogger } from '@affine/debug';
import type { CallbackMap, PluginContext } from '@affine/sdk/entry';
import type {
CallbackMap,
ExpectedLayout,
LayoutNode,
PluginContext,
} from '@affine/sdk/entry';
import { FormatQuickBar } from '@blocksuite/blocks';
import { assertExists } from '@blocksuite/global/utils';
import { DisposableGroup } from '@blocksuite/global/utils';
import {
addCleanup,
pluginEditorAtom,
pluginHeaderItemAtom,
pluginSettingAtom,
pluginWindowAtom,
} from '@toeverything/infra/__internal__/plugin';
import {
contentLayoutAtom,
currentPageAtom,
currentWorkspaceAtom,
editorItemsAtom,
headerItemsAtom,
rootStore,
settingItemsAtom,
windowItemsAtom,
} from '@toeverything/infra/atom';
import { atom } from 'jotai';
import { Provider } from 'jotai/react';
import { createElement, type PropsWithChildren } from 'react';
@@ -25,6 +33,84 @@ const dynamicImportKey = '$h_import';
const permissionLogger = new DebugLogger('plugins:permission');
const importLogger = new DebugLogger('plugins:import');
const pushLayoutAtom = atom<
null,
// fixme: check plugin name here
[pluginName: string, create: (root: HTMLElement) => () => void],
void
>(null, (_, set, pluginName, callback) => {
set(pluginWindowAtom, items => ({
...items,
[pluginName]: callback,
}));
set(contentLayoutAtom, layout => {
if (layout === 'editor') {
return {
direction: 'horizontal',
first: 'editor',
second: pluginName,
splitPercentage: 70,
};
} else {
return {
...layout,
direction: 'horizontal',
first: 'editor',
second: {
direction: 'horizontal',
// fixme: incorrect type here
first: layout.second,
second: pluginName,
splitPercentage: 70,
},
} as ExpectedLayout;
}
});
addCleanup(pluginName, () => {
set(deleteLayoutAtom, pluginName);
});
});
const deleteLayoutAtom = atom<null, [string], void>(null, (_, set, id) => {
set(pluginWindowAtom, items => {
const newItems = { ...items };
delete newItems[id];
return newItems;
});
const removeLayout = (layout: LayoutNode): LayoutNode => {
if (layout === 'editor') {
return 'editor';
} else {
if (typeof layout === 'string') {
return layout as ExpectedLayout;
}
if (layout.first === id) {
return layout.second;
} else if (layout.second === id) {
return layout.first;
} else {
return removeLayout(layout.second);
}
}
};
set(contentLayoutAtom, layout => {
if (layout === 'editor') {
return 'editor';
} else {
if (typeof layout === 'string') {
return layout as ExpectedLayout;
}
if (layout.first === id) {
return layout.second as ExpectedLayout;
} else if (layout.second === id) {
return layout.first as ExpectedLayout;
} else {
return removeLayout(layout.second) as ExpectedLayout;
}
}
});
});
// module -> importName -> updater[]
export const _rootImportsMap = new Map<string, Map<string, any>>();
const rootImportsMapSetupPromise = setupImportsMap(_rootImportsMap, {
@@ -41,7 +127,8 @@ const rootImportsMapSetupPromise = setupImportsMap(_rootImportsMap, {
rootStore: rootStore,
currentWorkspaceAtom: currentWorkspaceAtom,
currentPageAtom: currentPageAtom,
contentLayoutAtom: contentLayoutAtom,
pushLayoutAtom: pushLayoutAtom,
deleteLayoutAtom: deleteLayoutAtom,
},
'@blocksuite/blocks/std': import('@blocksuite/blocks/std'),
'@blocksuite/global/utils': import('@blocksuite/global/utils'),
@@ -379,7 +466,6 @@ const PluginProvider = ({ children }: PropsWithChildren) =>
children
);
const group = new DisposableGroup();
const entryLogger = new DebugLogger('plugin:entry');
export const evaluatePluginEntry = (pluginName: string) => {
@@ -392,29 +478,50 @@ export const evaluatePluginEntry = (pluginName: string) => {
register: (part, callback) => {
entryLogger.info(`Registering ${pluginName} to ${part}`);
if (part === 'headerItem') {
rootStore.set(headerItemsAtom, items => ({
rootStore.set(pluginHeaderItemAtom, items => ({
...items,
[pluginName]: callback as CallbackMap['headerItem'],
}));
addCleanup(pluginName, () => {
rootStore.set(pluginHeaderItemAtom, items => {
const newItems = { ...items };
delete newItems[pluginName];
return newItems;
});
});
} else if (part === 'editor') {
rootStore.set(editorItemsAtom, items => ({
rootStore.set(pluginEditorAtom, items => ({
...items,
[pluginName]: callback as CallbackMap['editor'],
}));
} else if (part === 'window') {
rootStore.set(windowItemsAtom, items => ({
...items,
[pluginName]: callback as CallbackMap['window'],
}));
addCleanup(pluginName, () => {
rootStore.set(pluginEditorAtom, items => {
const newItems = { ...items };
delete newItems[pluginName];
return newItems;
});
});
} else if (part === 'setting') {
rootStore.set(settingItemsAtom, items => ({
rootStore.set(pluginSettingAtom, items => ({
...items,
[pluginName]: callback as CallbackMap['setting'],
}));
addCleanup(pluginName, () => {
rootStore.set(pluginSettingAtom, items => {
const newItems = { ...items };
delete newItems[pluginName];
return newItems;
});
});
} else if (part === 'formatBar') {
FormatQuickBar.customElements.push((page, getBlockRange) => {
const div = document.createElement('div');
(callback as CallbackMap['formatBar'])(div, page, getBlockRange);
const cleanup = (callback as CallbackMap['formatBar'])(
div,
page,
getBlockRange
);
addCleanup(pluginName, cleanup);
return div;
});
} else {
@@ -428,5 +535,5 @@ export const evaluatePluginEntry = (pluginName: string) => {
if (typeof cleanup !== 'function') {
throw new Error('Plugin entry must return a function');
}
group.add(cleanup);
addCleanup(pluginName, cleanup);
};

View File

@@ -1,18 +1,16 @@
import { DebugLogger } from '@affine/debug';
import { registeredPluginAtom, rootStore } from '@toeverything/infra/atom';
import {
builtinPluginPaths,
enabledPluginAtom,
invokeCleanup,
pluginPackageJson,
} from '@toeverything/infra/__internal__/plugin';
import { loadedPluginNameAtom, rootStore } from '@toeverything/infra/atom';
import { packageJsonOutputSchema } from '@toeverything/infra/type';
import type { z } from 'zod';
import { evaluatePluginEntry, setupPluginCode } from './plugins/setup';
const builtinPluginUrl = new Set([
'/plugins/bookmark',
'/plugins/copilot',
'/plugins/hello-world',
'/plugins/image-preview',
'/plugins/vue-hello-world',
]);
const logger = new DebugLogger('register-plugins');
declare global {
@@ -20,10 +18,44 @@ declare global {
var __pluginPackageJson__: unknown[];
}
globalThis.__pluginPackageJson__ = [];
Object.defineProperty(globalThis, '__pluginPackageJson__', {
get() {
return rootStore.get(pluginPackageJson);
},
});
rootStore.sub(enabledPluginAtom, () => {
const added = new Set<string>();
const removed = new Set<string>();
const enabledPlugin = new Set(rootStore.get(enabledPluginAtom));
enabledPlugin.forEach(pluginName => {
if (!enabledPluginSet.has(pluginName)) {
added.add(pluginName);
}
});
enabledPluginSet.forEach(pluginName => {
if (!enabledPlugin.has(pluginName)) {
removed.add(pluginName);
}
});
// update plugins
enabledPluginSet.clear();
enabledPlugin.forEach(pluginName => {
enabledPluginSet.add(pluginName);
});
added.forEach(pluginName => {
evaluatePluginEntry(pluginName);
});
removed.forEach(pluginName => {
invokeCleanup(pluginName);
});
});
const enabledPluginSet = new Set(rootStore.get(enabledPluginAtom));
const loadedAssets = new Set<string>();
// we will load all plugins in parallel from builtinPlugins
export const pluginRegisterPromise = Promise.all(
[...builtinPluginUrl].map(url => {
[...builtinPluginPaths].map(url => {
return fetch(`${url}/package.json`)
.then(async res => {
const packageJson = (await res.json()) as z.infer<
@@ -38,28 +70,27 @@ export const pluginRegisterPromise = Promise.all(
assets,
},
} = packageJson;
globalThis.__pluginPackageJson__.push(packageJson);
rootStore.set(pluginPackageJson, json => [...json, packageJson]);
logger.debug(`registering plugin ${pluginName}`);
logger.debug(`package.json: ${packageJson}`);
if (!release && !runtimeConfig.enablePlugin) {
return Promise.resolve();
}
if (
release === 'development' &&
process.env.NODE_ENV !== 'development'
) {
return Promise.resolve();
}
const baseURL = url;
const entryURL = `${baseURL}/${core}`;
rootStore.set(registeredPluginAtom, prev => [...prev, pluginName]);
rootStore.set(loadedPluginNameAtom, prev => [...prev, pluginName]);
await setupPluginCode(baseURL, pluginName, core);
console.log(`prepareImports for ${pluginName} done`);
await fetch(entryURL).then(async () => {
if (assets.length > 0) {
await Promise.all(
assets.map(async (asset: string) => {
// todo(himself65): add assets into shadow dom
if (loadedAssets.has(asset)) {
return Promise.resolve();
}
if (asset.endsWith('.css')) {
loadedAssets.add(asset);
const res = await fetch(`${baseURL}/${asset}`);
if (res.ok) {
// todo: how to put css file into sandbox?
@@ -77,7 +108,12 @@ export const pluginRegisterPromise = Promise.all(
})
);
}
evaluatePluginEntry(pluginName);
if (!enabledPluginSet.has(pluginName)) {
logger.debug(`plugin ${pluginName} is not enabled`);
} else {
logger.debug(`plugin ${pluginName} is enabled`);
evaluatePluginEntry(pluginName);
}
});
})
.catch(e => {

View File

@@ -1,55 +1,96 @@
import { Switch } from '@affine/component';
import { SettingHeader } from '@affine/component/setting-components';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { CallbackMap } from '@affine/sdk/entry';
import {
registeredPluginAtom,
settingItemsAtom,
} from '@toeverything/infra/atom';
import { useAtomValue } from 'jotai';
import type { FC, ReactNode } from 'react';
import { useRef } from 'react';
addCleanup,
enabledPluginAtom,
pluginPackageJson,
pluginSettingAtom,
} from '@toeverything/infra/__internal__/plugin';
import { loadedPluginNameAtom } from '@toeverything/infra/atom';
import type { packageJsonOutputSchema } from '@toeverything/infra/type';
import { useAtom, useAtomValue } from 'jotai/react';
import { startTransition, useCallback, useMemo } from 'react';
import type { z } from 'zod';
import { pluginItem } from './style.css';
import { pluginItemStyle } from './style.css';
const PluginSettingWrapper: FC<{
id: string;
title?: ReactNode;
}> = ({ title, id }) => {
const Setting = useAtomValue(settingItemsAtom)[id];
const disposeRef = useRef<(() => void) | null>(null);
type PluginItemProps = {
json: z.infer<typeof packageJsonOutputSchema>;
};
type PluginSettingDetailProps = {
pluginName: string;
create: CallbackMap['setting'];
};
const PluginSettingDetail = ({
pluginName,
create,
}: PluginSettingDetailProps) => {
return (
<div>
{title ? <div className="title">{title}</div> : null}
<div
ref={ref => {
if (ref && Setting) {
setTimeout(() => {
disposeRef.current = Setting(ref);
});
} else if (ref === null) {
setTimeout(() => {
disposeRef.current?.();
});
<div
ref={useCallback(
(ref: HTMLDivElement | null) => {
if (ref) {
const cleanup = create(ref);
addCleanup(pluginName, cleanup);
}
}}
/>
},
[pluginName, create]
)}
/>
);
};
const PluginItem = ({ json }: PluginItemProps) => {
const [plugins, setEnabledPlugins] = useAtom(enabledPluginAtom);
const checked = useMemo(
() => plugins.includes(json.name),
[json.name, plugins]
);
const create = useAtomValue(pluginSettingAtom)[json.name];
return (
<div className={pluginItemStyle} key={json.name}>
<div>
{json.name}
<Switch
checked={checked}
onChange={useCallback(
(checked: boolean) => {
startTransition(() => {
setEnabledPlugins(plugins => {
if (checked) {
return [...plugins, json.name];
} else {
return plugins.filter(plugin => plugin !== json.name);
}
});
});
},
[json.name, setEnabledPlugins]
)}
/>
</div>
<div>{json.description}</div>
{create && <PluginSettingDetail pluginName={json.name} create={create} />}
</div>
);
};
export const Plugins = () => {
const t = useAFFiNEI18N();
const allowedPlugins = useAtomValue(registeredPluginAtom);
const loadedPlugins = useAtomValue(loadedPluginNameAtom);
return (
<>
<SettingHeader
title={'Plugins'}
subtitle={allowedPlugins.length === 0 && t['None yet']()}
subtitle={loadedPlugins.length === 0 && t['None yet']()}
data-testid="plugins-title"
/>
{allowedPlugins.map(plugin => (
<div className={pluginItem} key={plugin}>
<PluginSettingWrapper key={plugin} id={plugin} title={plugin} />
</div>
{useAtomValue(pluginPackageJson).map(json => (
<PluginItem json={json} key={json.name} />
))}
</>
);

View File

@@ -1,6 +1,6 @@
import { style } from '@vanilla-extract/css';
export const settingWrapper = style({
export const settingWrapperStyle = style({
flexGrow: 1,
display: 'flex',
justifyContent: 'flex-end',
@@ -8,7 +8,7 @@ export const settingWrapper = style({
maxWidth: '250px',
});
export const pluginItem = style({
export const pluginItemStyle = style({
borderBottom: '1px solid var(--affine-border-color)',
transition: '0.3s',
padding: '24px 8px',

View File

@@ -7,12 +7,16 @@ 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 { headerItemsAtom } from '@toeverything/infra/atom';
import {
addCleanup,
pluginHeaderItemAtom,
} from '@toeverything/infra/__internal__/plugin';
import clsx from 'clsx';
import { useAtom, useAtomValue } from 'jotai';
import type { FC, HTMLAttributes, PropsWithChildren, ReactNode } from 'react';
import {
forwardRef,
startTransition,
useCallback,
useEffect,
useMemo,
@@ -112,37 +116,39 @@ const WindowsAppControls = () => {
};
const PluginHeader = () => {
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);
div.style.display = 'flex';
const cleanup = headerItem(div);
root.appendChild(div);
return () => {
cleanup();
root.removeChild(div);
};
});
});
return () => {
clearTimeout(renderTimeout);
setTimeout(() => {
disposes.forEach(dispose => dispose());
});
};
}, [headerItems]);
return <div className={styles.pluginHeaderItems} ref={rootRef} />;
const headerItem = useAtomValue(pluginHeaderItemAtom);
const pluginsRef = useRef<string[]>([]);
return (
<div
className={styles.pluginHeaderItems}
ref={useCallback(
(root: HTMLDivElement | null) => {
if (root) {
Object.entries(headerItem).forEach(([pluginName, create]) => {
if (pluginsRef.current.includes(pluginName)) {
return;
}
pluginsRef.current.push(pluginName);
const div = document.createElement('div');
div.setAttribute('plugin-id', pluginName);
startTransition(() => {
const cleanup = create(div);
root.appendChild(div);
addCleanup(pluginName, () => {
pluginsRef.current = pluginsRef.current.filter(
name => name !== pluginName
);
root.removeChild(div);
cleanup();
});
});
});
}
},
[headerItem]
)}
/>
);
};
export const Header = forwardRef<

View File

@@ -1,7 +1,7 @@
import './page-detail-editor.css';
import { PageNotFoundError } from '@affine/env/constant';
import type { CallbackMap, LayoutNode } from '@affine/sdk//entry';
import type { LayoutNode } from '@affine/sdk//entry';
import { rootBlockHubAtom } from '@affine/workspace/atom';
import type { EditorContainer } from '@blocksuite/editor';
import { assertExists } from '@blocksuite/global/utils';
@@ -9,15 +9,15 @@ 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 {
contentLayoutAtom,
editorItemsAtom,
rootStore,
windowItemsAtom,
} from '@toeverything/infra/atom';
addCleanup,
pluginEditorAtom,
pluginWindowAtom,
} from '@toeverything/infra/__internal__/plugin';
import { contentLayoutAtom, rootStore } from '@toeverything/infra/atom';
import clsx from 'clsx';
import { useAtomValue, useSetAtom } from 'jotai';
import type { CSSProperties, FC, ReactElement } from 'react';
import { memo, Suspense, useCallback, useEffect, useMemo, useRef } from 'react';
import { memo, Suspense, useCallback, useMemo } from 'react';
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
import { pageSettingFamily } from '../atoms';
@@ -96,7 +96,7 @@ const EditorWrapper = memo(function EditorWrapper({
if (onLoad) {
dispose = onLoad(page, editor);
}
const editorItems = rootStore.get(editorItemsAtom);
const editorItems = rootStore.get(pluginEditorAtom);
let disposes: (() => void)[] = [];
const renderTimeout = setTimeout(() => {
disposes = Object.entries(editorItems).map(([id, editorItem]) => {
@@ -129,34 +129,28 @@ const EditorWrapper = memo(function EditorWrapper({
});
const PluginContentAdapter = memo<{
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} />;
windowItem: (div: HTMLDivElement) => () => void;
pluginName: string;
}>(function PluginContentAdapter({ windowItem, pluginName }) {
return (
<div
className={pluginContainer}
ref={useCallback(
(ref: HTMLDivElement | null) => {
if (ref) {
const div = document.createElement('div');
const cleanup = windowItem(div);
ref.appendChild(div);
addCleanup(pluginName, () => {
cleanup();
ref.removeChild(div);
});
}
},
[pluginName, windowItem]
)}
/>
);
});
type LayoutPanelProps = {
@@ -168,13 +162,13 @@ const LayoutPanel = memo(function LayoutPanel(
props: LayoutPanelProps
): ReactElement {
const node = props.node;
const windowItems = useAtomValue(windowItemsAtom);
const windowItems = useAtomValue(pluginWindowAtom);
if (typeof node === 'string') {
if (node === 'editor') {
return <EditorWrapper {...props.editorProps} />;
} else {
const windowItem = windowItems[node];
return <PluginContentAdapter windowItem={windowItem} />;
return <PluginContentAdapter pluginName={node} windowItem={windowItem} />;
}
} else {
return (