feat: add outline plugin (#3624)

Co-authored-by: codert <codert.sn@gmail.com>
(cherry picked from commit 6f9dfcc3c1)
This commit is contained in:
Alex Yang
2023-08-16 02:34:26 -05:00
parent 7f7bf6fef9
commit ab70ab2126
12 changed files with 312 additions and 71 deletions
+27 -31
View File
@@ -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, [string], void>(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,
@@ -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<PluginContentAdapterProps>(
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}
>
<Panel defaultSize={node.splitPercentage}>
<Panel
defaultSize={node.splitPercentage}
style={{
maxWidth: node.maxWidth?.[0],
}}
>
<Suspense>
<LayoutPanel node={node.first} editorProps={props.editorProps} />
</Suspense>
@@ -191,6 +198,7 @@ const LayoutPanel = memo(function LayoutPanel(
defaultSize={100 - node.splitPercentage}
style={{
overflow: 'scroll',
maxWidth: node.maxWidth?.[1],
}}
>
<Suspense>
@@ -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<string, (() => 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<
+9 -1
View File
@@ -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<null, [string], void>;
+29
View File
@@ -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"
}
}
+26
View File
@@ -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"]
}
+96
View File
@@ -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<HTMLDivElement>(null);
const tocPanelRef = useRef<TOCNotesPanel | null>(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 (
<div
className={`outline-wrapper`}
style={{
height: '100%',
borderLeft: `1px solid var(--affine-border-color)`,
}}
ref={ref}
/>
);
};
export const HeaderItem = ({
Provider,
}: {
Provider: ComponentType<PropsWithChildren>;
}) => {
const [open, setOpen] = useState(false);
const pushLayout = useSetAtom(pushLayoutAtom);
const deleteLayout = useSetAtom(deleteLayoutAtom);
return (
<Tooltip content="Plugin Enabled">
<IconButton
onClick={useCallback(() => {
if (!open) {
setOpen(true);
pushLayout(
'@affine/outline-plugin',
div => {
const root = createRoot(div);
div.style.height = '100%';
root.render(
<Provider>
<Outline />
</Provider>
);
return () => {
root.unmount();
};
},
{
maxWidth: [undefined, 300],
}
);
} else {
setOpen(false);
deleteLayout('@affine/outline-plugin');
}
}, [Provider, deleteLayout, open, pushLayout])}
>
<RightSidebarIcon />
</IconButton>
</Tooltip>
);
};
+5
View File
@@ -0,0 +1,5 @@
import { atom } from 'jotai';
export const blocksuiteRootAtom = atom(() =>
document.querySelector('block-suite-root')
);
+41
View File
@@ -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 () => {};
};
+17
View File
@@ -0,0 +1,17 @@
{
"extends": "../../tsconfig.json",
"include": ["./src"],
"compilerOptions": {
"noEmit": false,
"outDir": "lib",
"jsx": "preserve"
},
"references": [
{
"path": "../../packages/sdk"
},
{
"path": "../../packages/component"
}
]
}
+13 -29
View File
@@ -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(),
},
]);
}))
);
});
+31 -2
View File
@@ -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: