diff --git a/apps/core/src/bootstrap/plugins/setup.ts b/apps/core/src/bootstrap/plugins/setup.ts index d023874f15..cd4a176976 100644 --- a/apps/core/src/bootstrap/plugins/setup.ts +++ b/apps/core/src/bootstrap/plugins/setup.ts @@ -36,9 +36,13 @@ const importLogger = new DebugLogger('plugins:import'); const pushLayoutAtom = atom< null, // fixme: check plugin name here - [pluginName: string, create: (root: HTMLElement) => () => void], + [ + pluginName: string, + create: (root: HTMLElement) => () => void, + options: { maxWidth: (number | undefined)[] } | undefined, + ], void ->(null, (_, set, pluginName, callback) => { +>(null, (_, set, pluginName, callback, options) => { set(pluginWindowAtom, items => ({ ...items, [pluginName]: callback, @@ -50,20 +54,20 @@ const pushLayoutAtom = atom< first: 'editor', second: pluginName, splitPercentage: 70, + maxWidth: options?.maxWidth, }; } else { return { - ...layout, direction: 'horizontal', first: 'editor', + splitPercentage: 70, second: { direction: 'horizontal', - // fixme: incorrect type here - first: layout.second, - second: pluginName, - splitPercentage: 70, + first: pluginName, + second: layout.second, + splitPercentage: 50, }, - } as ExpectedLayout; + } satisfies ExpectedLayout; } }); addCleanup(pluginName, () => { @@ -77,36 +81,27 @@ const deleteLayoutAtom = atom(null, (_, set, id) => { delete newItems[id]; return newItems; }); - const removeLayout = (layout: LayoutNode): LayoutNode => { - if (layout === 'editor') { - return 'editor'; + const removeLayout = (layout: LayoutNode): LayoutNode | string => { + if (typeof layout === 'string') { + return layout; + } + if (layout.first === id) { + return layout.second; + } else if (layout.second === id) { + return layout.first; } 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); - } + return { + ...layout, + second: 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; - } + return removeLayout(layout) as ExpectedLayout; } }); }); @@ -123,6 +118,7 @@ const rootImportsMapSetupPromise = setupImportsMap(_rootImportsMap, { swr: import('swr'), '@affine/component': import('@affine/component'), '@blocksuite/icons': import('@blocksuite/icons'), + '@blocksuite/blocks': import('@blocksuite/blocks'), '@affine/sdk/entry': { rootStore: rootStore, currentWorkspaceAtom: currentWorkspaceAtom, diff --git a/apps/core/src/components/page-detail-editor.tsx b/apps/core/src/components/page-detail-editor.tsx index af6b310a90..a57d0d5899 100644 --- a/apps/core/src/components/page-detail-editor.tsx +++ b/apps/core/src/components/page-detail-editor.tsx @@ -17,7 +17,7 @@ import { contentLayoutAtom, rootStore } from '@toeverything/infra/atom'; import clsx from 'clsx'; import { useAtomValue, useSetAtom } from 'jotai'; import type { CSSProperties, ReactElement } from 'react'; -import { memo, Suspense, useCallback, useMemo } from 'react'; +import { memo, startTransition, Suspense, useCallback, useMemo } from 'react'; import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; import { pageSettingFamily } from '../atoms'; @@ -140,12 +140,14 @@ const PluginContentAdapter = memo( 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); + startTransition(() => { + const div = document.createElement('div'); + const cleanup = windowItem(div); + ref.appendChild(div); + addCleanup(pluginName, () => { + cleanup(); + ref.removeChild(div); + }); }); } }, @@ -181,7 +183,12 @@ const LayoutPanel = memo(function LayoutPanel( }} direction={node.direction} > - + @@ -191,6 +198,7 @@ const LayoutPanel = memo(function LayoutPanel( defaultSize={100 - node.splitPercentage} style={{ overflow: 'scroll', + maxWidth: node.maxWidth?.[1], }} > diff --git a/packages/infra/src/__internal__/plugin.ts b/packages/infra/src/__internal__/plugin.ts index a3ffa235b5..f5b3e5522f 100644 --- a/packages/infra/src/__internal__/plugin.ts +++ b/packages/infra/src/__internal__/plugin.ts @@ -11,6 +11,7 @@ export const builtinPluginPaths = new Set([ '/plugins/hello-world', '/plugins/image-preview', '/plugins/vue-hello-world', + '/plugins/outline', ]); const pluginCleanupMap = new Map void)[]>(); @@ -34,6 +35,7 @@ export const pluginPackageJson = atom< export const enabledPluginAtom = atomWithStorage('affine-enabled-plugin', [ '@affine/bookmark-plugin', '@affine/image-preview-plugin', + '@affine/outline-plugin', ]); export const pluginHeaderItemAtom = atom< diff --git a/packages/sdk/src/entry.ts b/packages/sdk/src/entry.ts index c7f3cda1d4..2ec3ad7e17 100644 --- a/packages/sdk/src/entry.ts +++ b/packages/sdk/src/entry.ts @@ -33,6 +33,7 @@ export type LayoutParentNode = { splitPercentage: number; // 0 - 100 first: string; second: LayoutNode; + maxWidth?: (number | undefined)[]; }; export type ExpectedLayout = @@ -48,7 +49,14 @@ export type ExpectedLayout = export declare const pushLayoutAtom: WritableAtom< null, - [string, (div: HTMLDivElement) => () => void], + | [ + string, + (div: HTMLDivElement) => () => void, + { + maxWidth: (number | undefined)[]; + }, + ] + | [string, (div: HTMLDivElement) => () => void], void >; export declare const deleteLayoutAtom: WritableAtom; diff --git a/plugins/outline/package.json b/plugins/outline/package.json new file mode 100644 index 0000000000..3a820c44c9 --- /dev/null +++ b/plugins/outline/package.json @@ -0,0 +1,29 @@ +{ + "name": "@affine/outline-plugin", + "type": "module", + "private": true, + "description": "Outline plugin", + "version": "0.8.0-canary.13", + "scripts": { + "dev": "af dev", + "build": "af build" + }, + "affinePlugin": { + "release": "development", + "entry": { + "core": "./src/index.ts" + } + }, + "dependencies": { + "@affine/component": "workspace:*", + "@affine/sdk": "workspace:*", + "@blocksuite/icons": "^2.1.29", + "@toeverything/components": "^0.0.8" + }, + "devDependencies": { + "@affine/plugin-cli": "workspace:*", + "jotai": "^2.2.2", + "react": "18.2.0", + "react-dom": "18.2.0" + } +} diff --git a/plugins/outline/project.json b/plugins/outline/project.json new file mode 100644 index 0000000000..75d37b3753 --- /dev/null +++ b/plugins/outline/project.json @@ -0,0 +1,26 @@ +{ + "name": "@affine/outline-plugin", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "namedInputs": { + "default": [ + "{projectRoot}/**/*", + "{workspaceRoot}/packages/plugin-cli/src/**/*", + "sharedGlobals" + ] + }, + "targets": { + "build": { + "executor": "nx:run-script", + "options": { + "script": "build" + }, + "dependsOn": ["^build"], + "inputs": ["default"], + "outputs": [ + "{workspaceRoot}/apps/core/public/plugins/outline", + "{workspaceRoot}/apps/electron/dist/plugins/outline" + ] + } + }, + "tags": ["plugin"] +} diff --git a/plugins/outline/src/app.tsx b/plugins/outline/src/app.tsx new file mode 100644 index 0000000000..afb7960eac --- /dev/null +++ b/plugins/outline/src/app.tsx @@ -0,0 +1,96 @@ +import { Tooltip } from '@affine/component'; +import { deleteLayoutAtom, pushLayoutAtom } from '@affine/sdk/entry'; +import { TOCNotesPanel } from '@blocksuite/blocks'; +import { RightSidebarIcon } from '@blocksuite/icons'; +import type { Page } from '@blocksuite/store'; +import { IconButton } from '@toeverything/components/button'; +import { useAtom, useSetAtom } from 'jotai'; +import type { ComponentType, PropsWithChildren } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { createRoot } from 'react-dom/client'; + +import { blocksuiteRootAtom } from './atom'; + +const Outline = () => { + const ref = useRef(null); + const tocPanelRef = useRef(null); + const [blocksuite] = useAtom(blocksuiteRootAtom); + + if (!tocPanelRef.current) { + tocPanelRef.current = new TOCNotesPanel(); + } + + if (blocksuite?.page !== tocPanelRef.current?.page) { + (tocPanelRef.current as TOCNotesPanel).page = blocksuite?.page as Page; + } + + useEffect(() => { + if (!ref.current || !tocPanelRef.current) return; + + const container = ref.current; + const tocPanel = tocPanelRef.current as TOCNotesPanel; + + container.appendChild(tocPanel); + + return () => { + container.removeChild(tocPanel); + }; + }, []); + + return ( +
+ ); +}; + +export const HeaderItem = ({ + Provider, +}: { + Provider: ComponentType; +}) => { + const [open, setOpen] = useState(false); + const pushLayout = useSetAtom(pushLayoutAtom); + const deleteLayout = useSetAtom(deleteLayoutAtom); + return ( + + { + if (!open) { + setOpen(true); + pushLayout( + '@affine/outline-plugin', + div => { + const root = createRoot(div); + + div.style.height = '100%'; + + root.render( + + + + ); + return () => { + root.unmount(); + }; + }, + { + maxWidth: [undefined, 300], + } + ); + } else { + setOpen(false); + deleteLayout('@affine/outline-plugin'); + } + }, [Provider, deleteLayout, open, pushLayout])} + > + + + + ); +}; diff --git a/plugins/outline/src/atom.ts b/plugins/outline/src/atom.ts new file mode 100644 index 0000000000..3a2f13a39f --- /dev/null +++ b/plugins/outline/src/atom.ts @@ -0,0 +1,5 @@ +import { atom } from 'jotai'; + +export const blocksuiteRootAtom = atom(() => + document.querySelector('block-suite-root') +); diff --git a/plugins/outline/src/index.ts b/plugins/outline/src/index.ts new file mode 100644 index 0000000000..30eacb92f0 --- /dev/null +++ b/plugins/outline/src/index.ts @@ -0,0 +1,41 @@ +import type { PluginContext } from '@affine/sdk/entry'; +import { registerTOCComponents } from '@blocksuite/blocks'; +import { createElement } from 'react'; +import { createRoot } from 'react-dom/client'; + +import { HeaderItem } from './app'; + +export const entry = (context: PluginContext) => { + console.log('register outline'); + + context.register('headerItem', div => { + registerTOCComponents(components => { + for (const compName in components) { + if (window.customElements.get(compName)) continue; + + window.customElements.define( + compName, + components[compName as keyof typeof components] + ); + } + }); + + div.style.height = '100%'; + + const root = createRoot(div); + root.render( + createElement( + context.utils.PluginProvider, + {}, + createElement(HeaderItem, { + Provider: context.utils.PluginProvider, + }) + ) + ); + return () => { + root.unmount(); + }; + }); + + return () => {}; +}; diff --git a/plugins/outline/tsconfig.json b/plugins/outline/tsconfig.json new file mode 100644 index 0000000000..88ac2ab9f9 --- /dev/null +++ b/plugins/outline/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.json", + "include": ["./src"], + "compilerOptions": { + "noEmit": false, + "outDir": "lib", + "jsx": "preserve" + }, + "references": [ + { + "path": "../../packages/sdk" + }, + { + "path": "../../packages/component" + } + ] +} diff --git a/tests/affine-plugin/e2e/basic.spec.ts b/tests/affine-plugin/e2e/basic.spec.ts index 2b0dbbab7a..c494fe524c 100644 --- a/tests/affine-plugin/e2e/basic.spec.ts +++ b/tests/affine-plugin/e2e/basic.spec.ts @@ -19,36 +19,20 @@ test('plugin should exist', async ({ page }) => { // @ts-expect-error return window.__pluginPackageJson__; }); - expect(packageJson).toEqual([ - { - name: '@affine/bookmark-plugin', + const plugins = [ + '@affine/bookmark-plugin', + '@affine/copilot-plugin', + '@affine/hello-world-plugin', + '@affine/image-preview-plugin', + '@affine/vue-hello-world-plugin', + '@affine/outline-plugin', + ]; + expect(packageJson).toEqual( + plugins.map(name => ({ + name, version: expect.any(String), description: expect.any(String), affinePlugin: expect.anything(), - }, - { - name: '@affine/copilot-plugin', - version: expect.any(String), - description: expect.any(String), - affinePlugin: expect.anything(), - }, - { - name: '@affine/hello-world-plugin', - version: expect.any(String), - description: expect.any(String), - affinePlugin: expect.anything(), - }, - { - name: '@affine/image-preview-plugin', - version: expect.any(String), - description: expect.any(String), - affinePlugin: expect.anything(), - }, - { - name: '@affine/vue-hello-world-plugin', - version: expect.any(String), - description: expect.any(String), - affinePlugin: expect.anything(), - }, - ]); + })) + ); }); diff --git a/yarn.lock b/yarn.lock index 2fcf4b5a52..b6ab312819 100644 --- a/yarn.lock +++ b/yarn.lock @@ -554,6 +554,21 @@ __metadata: languageName: unknown linkType: soft +"@affine/outline-plugin@workspace:plugins/outline": + version: 0.0.0-use.local + resolution: "@affine/outline-plugin@workspace:plugins/outline" + dependencies: + "@affine/component": "workspace:*" + "@affine/plugin-cli": "workspace:*" + "@affine/sdk": "workspace:*" + "@blocksuite/icons": ^2.1.29 + "@toeverything/components": ^0.0.8 + jotai: ^2.2.2 + react: 18.2.0 + react-dom: 18.2.0 + languageName: unknown + linkType: soft + "@affine/plugin-cli@workspace:*, @affine/plugin-cli@workspace:packages/plugin-cli": version: 0.0.0-use.local resolution: "@affine/plugin-cli@workspace:packages/plugin-cli" @@ -3441,7 +3456,7 @@ __metadata: languageName: node linkType: hard -"@blocksuite/icons@npm:^2.1.30, @blocksuite/icons@npm:^2.1.31": +"@blocksuite/icons@npm:^2.1.26, @blocksuite/icons@npm:^2.1.29, @blocksuite/icons@npm:^2.1.30, @blocksuite/icons@npm:^2.1.31": version: 2.1.31 resolution: "@blocksuite/icons@npm:2.1.31" peerDependencies: @@ -11424,6 +11439,20 @@ __metadata: languageName: node linkType: hard +"@toeverything/components@npm:^0.0.8": + version: 0.0.8 + resolution: "@toeverything/components@npm:0.0.8" + dependencies: + "@blocksuite/icons": ^2.1.26 + peerDependencies: + "@radix-ui/react-avatar": ^1 + clsx: ^2 + react: ^18 + react-dom: ^18 + checksum: 9c955d3c46729397e92031cc6a5ba0f15eb8a67893862b4c6c017c87fff2ab8e9ee8505695b0da571f2af35ad6545f643fc34f657f1c9d1913648796971540d9 + languageName: node + linkType: hard + "@toeverything/hooks@workspace:*, @toeverything/hooks@workspace:packages/hooks": version: 0.0.0-use.local resolution: "@toeverything/hooks@workspace:packages/hooks" @@ -22753,7 +22782,7 @@ __metadata: languageName: node linkType: hard -"jotai@npm:^2.3.1": +"jotai@npm:^2.2.2, jotai@npm:^2.3.1": version: 2.3.1 resolution: "jotai@npm:2.3.1" peerDependencies: