diff --git a/apps/core/src/_plugin/index.test.tsx b/apps/core/src/_plugin/index.test.tsx index 203f189e88..0dc0654e3c 100644 --- a/apps/core/src/_plugin/index.test.tsx +++ b/apps/core/src/_plugin/index.test.tsx @@ -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); diff --git a/apps/core/src/bootstrap/plugins/setup.ts b/apps/core/src/bootstrap/plugins/setup.ts index 02dd80d952..00c5aea6a3 100644 --- a/apps/core/src/bootstrap/plugins/setup.ts +++ b/apps/core/src/bootstrap/plugins/setup.ts @@ -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, (_, 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>(); 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); }; diff --git a/apps/core/src/bootstrap/register-plugins.ts b/apps/core/src/bootstrap/register-plugins.ts index 5fc4516e84..4170407ad5 100644 --- a/apps/core/src/bootstrap/register-plugins.ts +++ b/apps/core/src/bootstrap/register-plugins.ts @@ -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(); + const removed = new Set(); + 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(); + +// 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 => { diff --git a/apps/core/src/components/affine/setting-modal/general-setting/plugins/index.tsx b/apps/core/src/components/affine/setting-modal/general-setting/plugins/index.tsx index 4c527ccd76..02d29e954f 100644 --- a/apps/core/src/components/affine/setting-modal/general-setting/plugins/index.tsx +++ b/apps/core/src/components/affine/setting-modal/general-setting/plugins/index.tsx @@ -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; +}; + +type PluginSettingDetailProps = { + pluginName: string; + create: CallbackMap['setting']; +}; + +const PluginSettingDetail = ({ + pluginName, + create, +}: PluginSettingDetailProps) => { return ( -
- {title ?
{title}
: null} -
{ - if (ref && Setting) { - setTimeout(() => { - disposeRef.current = Setting(ref); - }); - } else if (ref === null) { - setTimeout(() => { - disposeRef.current?.(); - }); +
{ + 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 ( +
+
+ {json.name} + { + startTransition(() => { + setEnabledPlugins(plugins => { + if (checked) { + return [...plugins, json.name]; + } else { + return plugins.filter(plugin => plugin !== json.name); + } + }); + }); + }, + [json.name, setEnabledPlugins] + )} + /> +
+
{json.description}
+ {create && }
); }; export const Plugins = () => { const t = useAFFiNEI18N(); - const allowedPlugins = useAtomValue(registeredPluginAtom); + const loadedPlugins = useAtomValue(loadedPluginNameAtom); return ( <> - {allowedPlugins.map(plugin => ( -
- -
+ {useAtomValue(pluginPackageJson).map(json => ( + ))} ); diff --git a/apps/core/src/components/affine/setting-modal/general-setting/plugins/style.css.ts b/apps/core/src/components/affine/setting-modal/general-setting/plugins/style.css.ts index 36bf3031cd..0f122181e4 100644 --- a/apps/core/src/components/affine/setting-modal/general-setting/plugins/style.css.ts +++ b/apps/core/src/components/affine/setting-modal/general-setting/plugins/style.css.ts @@ -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', diff --git a/apps/core/src/components/blocksuite/workspace-header/header.tsx b/apps/core/src/components/blocksuite/workspace-header/header.tsx index 16f0bc6232..ae30fa5213 100644 --- a/apps/core/src/components/blocksuite/workspace-header/header.tsx +++ b/apps/core/src/components/blocksuite/workspace-header/header.tsx @@ -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(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
; + const headerItem = useAtomValue(pluginHeaderItemAtom); + const pluginsRef = useRef([]); + return ( +
{ + 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< diff --git a/apps/core/src/components/page-detail-editor.tsx b/apps/core/src/components/page-detail-editor.tsx index a5fd47a449..3b8083d6b4 100644 --- a/apps/core/src/components/page-detail-editor.tsx +++ b/apps/core/src/components/page-detail-editor.tsx @@ -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(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
; + windowItem: (div: HTMLDivElement) => () => void; + pluginName: string; +}>(function PluginContentAdapter({ windowItem, pluginName }) { + return ( +
{ + 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 ; } else { const windowItem = windowItems[node]; - return ; + return ; } } else { return ( diff --git a/packages/infra/package.json b/packages/infra/package.json index 0c852f025f..f1de4d2835 100644 --- a/packages/infra/package.json +++ b/packages/infra/package.json @@ -30,15 +30,10 @@ "import": "./dist/type.js", "require": "./dist/type.cjs" }, - "./__internal__/workspace": { - "type": "./dist/src/__internal__/workspace.d.ts", - "import": "./dist/__internal__/workspace.js", - "require": "./dist/__internal__/workspace.cjs" - }, - "./__internal__/react": { - "type": "./dist/src/__internal__/react.d.ts", - "import": "./dist/__internal__/react.js", - "require": "./dist/__internal__/react.cjs" + "./__internal__/*": { + "type": "./dist/src/__internal__/*.d.ts", + "import": "./dist/__internal__/*.js", + "require": "./dist/__internal__/*.cjs" } }, "files": [ diff --git a/packages/infra/src/__internal__/plugin.ts b/packages/infra/src/__internal__/plugin.ts new file mode 100644 index 0000000000..a3ffa235b5 --- /dev/null +++ b/packages/infra/src/__internal__/plugin.ts @@ -0,0 +1,51 @@ +import type { CallbackMap } from '@affine/sdk/entry'; +import { atomWithStorage } from 'jotai/utils'; +import { atom } from 'jotai/vanilla'; +import type { z } from 'zod'; + +import type { packageJsonOutputSchema } from '../type.js'; + +export const builtinPluginPaths = new Set([ + '/plugins/bookmark', + '/plugins/copilot', + '/plugins/hello-world', + '/plugins/image-preview', + '/plugins/vue-hello-world', +]); + +const pluginCleanupMap = new Map void)[]>(); + +export function addCleanup(pluginName: string, cleanup: () => void) { + if (!pluginCleanupMap.has(pluginName)) { + pluginCleanupMap.set(pluginName, []); + } + pluginCleanupMap.get(pluginName)?.push(cleanup); +} + +export function invokeCleanup(pluginName: string) { + pluginCleanupMap.get(pluginName)?.forEach(cleanup => cleanup()); + pluginCleanupMap.delete(pluginName); +} + +export const pluginPackageJson = atom< + z.infer[] +>([]); + +export const enabledPluginAtom = atomWithStorage('affine-enabled-plugin', [ + '@affine/bookmark-plugin', + '@affine/image-preview-plugin', +]); + +export const pluginHeaderItemAtom = atom< + Record +>({}); + +export const pluginSettingAtom = atom>( + {} +); + +export const pluginEditorAtom = atom>({}); + +export const pluginWindowAtom = atom< + Record () => void> +>({}); diff --git a/packages/infra/src/atom.ts b/packages/infra/src/atom.ts index 2da6ed3b87..7169ac27ae 100644 --- a/packages/infra/src/atom.ts +++ b/packages/infra/src/atom.ts @@ -1,4 +1,4 @@ -import type { CallbackMap, ExpectedLayout } from '@affine/sdk/entry'; +import type { ExpectedLayout } from '@affine/sdk/entry'; import { assertExists } from '@blocksuite/global/utils'; import type { Page, Workspace } from '@blocksuite/store'; import { atom, createStore } from 'jotai/vanilla'; @@ -7,20 +7,7 @@ import { getWorkspace, waitForWorkspace } from './__internal__/workspace.js'; // global store export const rootStore = createStore(); - -// id -> HTML element -export const headerItemsAtom = atom>( - {} -); -export const editorItemsAtom = atom>({}); -export const registeredPluginAtom = atom([]); -export const windowItemsAtom = atom>({}); -export const settingItemsAtom = atom>( - {} -); -export const formatBarItemsAtom = atom< - Record ->({}); +export const loadedPluginNameAtom = atom([]); export const currentWorkspaceIdAtom = atom(null); export const currentPageIdAtom = atom(null); diff --git a/packages/infra/vite.config.ts b/packages/infra/vite.config.ts index a920b0ede4..0788cad207 100644 --- a/packages/infra/vite.config.ts +++ b/packages/infra/vite.config.ts @@ -21,6 +21,7 @@ export default defineConfig({ 'src/__internal__/workspace.ts' ), '__internal__/react': resolve(root, 'src/__internal__/react.ts'), + '__internal__/plugin': resolve(root, 'src/__internal__/plugin.ts'), }, formats: ['es', 'cjs'], name: 'AffineInfra', diff --git a/packages/sdk/src/entry.ts b/packages/sdk/src/entry.ts index 34bb4e1151..7388157318 100644 --- a/packages/sdk/src/entry.ts +++ b/packages/sdk/src/entry.ts @@ -2,14 +2,14 @@ import type { getCurrentBlockRange } from '@blocksuite/blocks'; import type { EditorContainer } from '@blocksuite/editor'; import type { Page } from '@blocksuite/store'; import type { Workspace } from '@blocksuite/store'; -import type { Atom, getDefaultStore, PrimitiveAtom } from 'jotai/vanilla'; +import type { Atom, getDefaultStore } from 'jotai/vanilla'; +import type { WritableAtom } from 'jotai/vanilla/atom'; import type { FC } from 'react'; -export type Part = 'headerItem' | 'editor' | 'window' | 'setting' | 'formatBar'; +export type Part = 'headerItem' | 'editor' | 'setting' | 'formatBar'; export type CallbackMap = { headerItem: (root: HTMLElement) => () => void; - window: (root: HTMLElement) => () => void; editor: (root: HTMLElement, editor: EditorContainer) => () => void; setting: (root: HTMLElement) => () => void; formatBar: ( @@ -31,13 +31,13 @@ export type LayoutNode = LayoutParentNode | string; export type LayoutParentNode = { direction: LayoutDirection; splitPercentage: number; // 0 - 100 - first: LayoutNode; + first: string; second: LayoutNode; }; export type ExpectedLayout = | { - direction: LayoutDirection; + direction: 'horizontal'; // the first element is always the editor first: 'editor'; second: LayoutNode; @@ -46,7 +46,12 @@ export type ExpectedLayout = } | 'editor'; -export declare const contentLayoutAtom: PrimitiveAtom; +export declare const pushLayoutAtom: WritableAtom< + null, + [string, (div: HTMLDivElement) => () => void], + void +>; +export declare const deleteLayoutAtom: WritableAtom; export declare const currentPageAtom: Atom>; export declare const currentWorkspaceAtom: Atom>; export declare const rootStore: ReturnType; diff --git a/plugins/copilot/src/UI/header-item.tsx b/plugins/copilot/src/UI/header-item.tsx index 8f8d8ea883..7429edb5b6 100644 --- a/plugins/copilot/src/UI/header-item.tsx +++ b/plugins/copilot/src/UI/header-item.tsx @@ -1,32 +1,43 @@ import { IconButton, Tooltip } from '@affine/component'; -import { contentLayoutAtom } from '@affine/sdk/entry'; +import { deleteLayoutAtom, pushLayoutAtom } from '@affine/sdk/entry'; import { AiIcon } from '@blocksuite/icons'; import { useSetAtom } from 'jotai'; -import type { ReactElement } from 'react'; -import { useCallback } from 'react'; +import type { ComponentType, PropsWithChildren, ReactElement } from 'react'; +import { useCallback, useState } from 'react'; +import { createRoot } from 'react-dom/client'; -export const HeaderItem = (): ReactElement => { - const setLayout = useSetAtom(contentLayoutAtom); +import { DetailContent } from './detail-content'; + +export const HeaderItem = ({ + Provider, +}: { + Provider: ComponentType; +}): ReactElement => { + const [open, setOpen] = useState(false); + const pushLayout = useSetAtom(pushLayoutAtom); + const deleteLayout = useSetAtom(deleteLayoutAtom); return ( - // todo: abstract a context function to open a new tab - setLayout(layout => { - if (layout === 'editor') { - return { - direction: 'horizontal', - first: 'editor', - second: '@affine/copilot-plugin', - splitPercentage: 70, - }; - } else { - return 'editor'; - } - }), - [setLayout] - )} + onClick={useCallback(() => { + if (!open) { + setOpen(true); + pushLayout('@affine/copilot-plugin', div => { + const root = createRoot(div); + root.render( + + + + ); + return () => { + root.unmount(); + }; + }); + } else { + setOpen(false); + deleteLayout('@affine/copilot-plugin'); + } + }, [Provider, deleteLayout, open, pushLayout])} > diff --git a/plugins/copilot/src/index.ts b/plugins/copilot/src/index.ts index 8e7fc63761..a30206078c 100644 --- a/plugins/copilot/src/index.ts +++ b/plugins/copilot/src/index.ts @@ -3,28 +3,19 @@ import { createElement } from 'react'; import { createRoot } from 'react-dom/client'; import { DebugContent } from './UI/debug-content'; -import { DetailContent } from './UI/detail-content'; import { HeaderItem } from './UI/header-item'; export const entry = (context: PluginContext) => { console.log('copilot entry'); context.register('headerItem', div => { - const root = createRoot(div); - root.render( - createElement(context.utils.PluginProvider, {}, createElement(HeaderItem)) - ); - return () => { - root.unmount(); - }; - }); - - context.register('window', div => { const root = createRoot(div); root.render( createElement( context.utils.PluginProvider, {}, - createElement(DetailContent) + createElement(HeaderItem, { + Provider: context.utils.PluginProvider, + }) ) ); return () => {