diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index 74184d5200ef..24b4ab9cf72f 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -94,6 +94,7 @@ const nextConfig = { '@affine/workspace', '@affine/jotai', '@affine/copilot', + '@affine/outline', '@toeverything/hooks', '@toeverything/y-indexeddb', '@toeverything/infra', diff --git a/apps/web/package.json b/apps/web/package.json index 70e9a3b4ab5c..938f641ac8e0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -17,6 +17,7 @@ "@affine/graphql": "workspace:*", "@affine/i18n": "workspace:*", "@affine/jotai": "workspace:*", + "@affine/outline": "workspace:*", "@affine/templates": "workspace:*", "@affine/workspace": "workspace:*", "@blocksuite/block-std": "0.0.0-20230708145134-cac23f63-nightly", diff --git a/apps/web/src/bootstrap/index.ts b/apps/web/src/bootstrap/index.ts index ee4084e91f87..518cdf9b4be8 100644 --- a/apps/web/src/bootstrap/index.ts +++ b/apps/web/src/bootstrap/index.ts @@ -39,6 +39,10 @@ if (!environment.isServer) { import('@affine/bookmark-block'); } +if (!environment.isServer) { + import('@affine/outline'); +} + // platform check { if (globalThis.platform) { diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 6705fa73d13c..9e503eaea172 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -61,6 +61,9 @@ { "path": "../../plugins/copilot" }, + { + "path": "../../plugins/outline" + }, // Static Server { diff --git a/plugins/outline/package.json b/plugins/outline/package.json new file mode 100644 index 000000000000..a6662474a0ba --- /dev/null +++ b/plugins/outline/package.json @@ -0,0 +1,26 @@ +{ + "name": "@affine/outline", + "private": true, + "main": "./src/index.ts", + "module": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "dependencies": { + "@affine/component": "workspace:*", + "@toeverything/plugin-infra": "workspace:*", + "smooth-scroll-into-view-if-needed": "^2.0.0" + }, + "devDependencies": { + "@types/react": "^18.2.14", + "@types/react-dom": "^18.2.6", + "idb": "^7.1.1", + "jotai": "^2.2.2", + "react": "18.3.0-canary-1fdacbefd-20230630" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + }, + "version": "0.7.0-canary.36" +} diff --git a/plugins/outline/src/blocksuite/index.css.ts b/plugins/outline/src/blocksuite/index.css.ts new file mode 100644 index 000000000000..fcb0d2c58643 --- /dev/null +++ b/plugins/outline/src/blocksuite/index.css.ts @@ -0,0 +1,51 @@ +import { style } from '@vanilla-extract/css'; + +// a sider menu style +export const outlineContainerStyle = style({ + display: 'flex', + flexDirection: 'column', + height: '100%', + padding: '0px 4px', + top: '200px', + position: 'absolute', + width: '200px', + backgroundColor: 'var(--affine-background-color)', + color: 'var(--affine-text-primary-color)', +}); + +export const outlineHeaderStyle = style({ + fontWeight: 'bold', + marginBottom: '20px', +}); + +export const outlineContentStyle = style({}); + +export const outlineMenuItemStyle = style({ + cursor: 'pointer', + padding: '4px', + display: 'flex', + alignItems: 'center', + whiteSpace: 'nowrap', + overflow: 'hidden', + margin: '2px 0', + textOverflow: 'ellipsis', + color: 'var(--affine-text-primary-color)', + ':hover': { + backgroundColor: 'var(--affine-hover-color)', + }, + ':active': { + backgroundColor: 'var(--affine-hover-color)', + }, + ':focus': { + backgroundColor: 'var(--affine-hover-color)', + }, +}); + +export const outlineItemContentStyle = style({ + display: 'inline-block', + opacity: 0.6, + maxWidth: '100%', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +}); diff --git a/plugins/outline/src/blocksuite/index.tsx b/plugins/outline/src/blocksuite/index.tsx new file mode 100644 index 000000000000..a65bc68dc522 --- /dev/null +++ b/plugins/outline/src/blocksuite/index.tsx @@ -0,0 +1,27 @@ +import type { PluginBlockSuiteAdapter } from '@toeverything/plugin-infra/type'; +import { noop } from 'foxact/noop'; +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; + +import { OutlineUI } from './ui'; + +export default { + uiDecorator: editor => { + if (editor.parentElement) { + const div = document.createElement('div'); + editor.parentElement.appendChild(div); + const root = createRoot(div); + root.render( + + + + ); + return () => { + root.unmount(); + div.remove(); + }; + } else { + return noop; + } + }, +} satisfies Partial; diff --git a/plugins/outline/src/blocksuite/ui.tsx b/plugins/outline/src/blocksuite/ui.tsx new file mode 100644 index 000000000000..b12ed21e42f6 --- /dev/null +++ b/plugins/outline/src/blocksuite/ui.tsx @@ -0,0 +1,81 @@ +import type { PageBlockModel } from '@blocksuite/blocks'; +import type { Page } from '@blocksuite/store'; +import type { FC } from 'react'; +import { useCallback } from 'react'; +import scrollIntoView from 'scroll-into-view-if-needed'; + +import { + outlineContainerStyle, + outlineContentStyle, + outlineHeaderStyle, + outlineItemContentStyle, + outlineMenuItemStyle, +} from './index.css'; + +export type OutlineProps = { + page: Page; +}; +function isHeading(str?: string) { + return /^h[1-6]$/.test(str ?? ''); +} +const getOutlineStructure = (root: PageBlockModel) => { + const children = root.children; + const directoryTree: { + level: number; + title: string; + id: string; + }[] = []; + for (const child of children) { + for (const heading of child.children) { + if (heading.type && isHeading(heading.type)) { + console.log(heading + '1'); + const headingLevel = parseInt(heading.type.charAt(1)); + directoryTree.push({ + level: headingLevel, + id: heading.id, + title: heading.text?.toString() ?? '', + }); + } + } + } + return directoryTree; +}; + +export const OutlineUI: FC = ({ page }) => { + const root = page.root as PageBlockModel; + const tree = getOutlineStructure(root); + const handleNav = useCallback((id: string) => { + const target = document.querySelector(`[data-block-id="${id}"]`); + if (!target) return; + scrollIntoView(target, { + behavior: 'smooth', + scrollMode: 'always', + block: 'center', + inline: 'center', + }); + }, []); + return ( + + Outline + + {tree.map((item, index) => { + if (!item.title) return null; + return ( + handleNav(item.id)} + key={index} + className={outlineMenuItemStyle} + > + + {item.title} + + + ); + })} + + + ); +}; diff --git a/plugins/outline/src/index.ts b/plugins/outline/src/index.ts new file mode 100644 index 000000000000..e64460eb81a7 --- /dev/null +++ b/plugins/outline/src/index.ts @@ -0,0 +1,35 @@ +import { definePlugin } from '@toeverything/plugin-infra/manager'; +import { ReleaseStage } from '@toeverything/plugin-infra/type'; + +definePlugin( + { + id: 'com.affine.outline', + name: { + fallback: 'AFFiNE Outline', + i18nKey: 'com.affine.outline.name', + }, + description: { + fallback: + 'AFFiNE Outline will help you with best writing experience on the World.', + }, + publisher: { + name: { + fallback: 'AFFiNE', + i18nKey: 'com.affine.org', + }, + link: 'https://affine.pro', + }, + stage: ReleaseStage.NIGHTLY, + commands: [], + version: '0.0.1', + }, + undefined, + { + load: () => import('./blocksuite/index'), + hotModuleReload: onHot => + import.meta.webpackHot && + import.meta.webpackHot.accept('./blocksuite', () => + onHot(import('./blocksuite/index')) + ), + } +); diff --git a/plugins/outline/tsconfig.json b/plugins/outline/tsconfig.json new file mode 100644 index 000000000000..85a28c467d46 --- /dev/null +++ b/plugins/outline/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.json", + "include": ["./src"], + "compilerOptions": { + "noEmit": false, + "outDir": "lib" + }, + "references": [ + { + "path": "../../packages/component" + }, + { + "path": "../../packages/plugin-infra" + }, + { + "path": "../../packages/env" + } + ] +} diff --git a/tsconfig.json b/tsconfig.json index 7d3f997c3dcb..86a7e27146d7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -69,6 +69,8 @@ "@affine/graphql": ["./packages/graphql/src"], "@affine/copilot": ["./plugins/copilot/src"], "@affine/copilot/*": ["./plugins/copilot/src/*"], + "@affine/outline": ["./plugins/outline/src"], + "@affine/outline/*": ["./plugins/outline/src/*"], "@affine/electron/scripts/*": ["./apps/electron/scripts/*"], "@affine-test/kit/*": ["./tests/kit/*"], "@affine-test/fixtures/*": ["./tests/fixtures/*"], @@ -120,6 +122,9 @@ { "path": "./plugins/copilot" }, + { + "path": "./plugins/outline" + }, // Tests { diff --git a/yarn.lock b/yarn.lock index 4bb6ca0e2b24..c397e98b934b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -397,6 +397,24 @@ __metadata: languageName: unknown linkType: soft +"@affine/outline@workspace:*, @affine/outline@workspace:plugins/outline": + version: 0.0.0-use.local + resolution: "@affine/outline@workspace:plugins/outline" + dependencies: + "@affine/component": "workspace:*" + "@toeverything/plugin-infra": "workspace:*" + "@types/react": ^18.2.14 + "@types/react-dom": ^18.2.6 + idb: ^7.1.1 + jotai: ^2.2.2 + react: 18.3.0-canary-1fdacbefd-20230630 + smooth-scroll-into-view-if-needed: ^2.0.0 + peerDependencies: + react: "*" + react-dom: "*" + languageName: unknown + linkType: soft + "@affine/server@workspace:apps/server": version: 0.0.0-use.local resolution: "@affine/server@workspace:apps/server" @@ -518,6 +536,7 @@ __metadata: "@affine/graphql": "workspace:*" "@affine/i18n": "workspace:*" "@affine/jotai": "workspace:*" + "@affine/outline": "workspace:*" "@affine/templates": "workspace:*" "@affine/workspace": "workspace:*" "@blocksuite/block-std": 0.0.0-20230708145134-cac23f63-nightly @@ -15326,6 +15345,13 @@ __metadata: languageName: node linkType: hard +"compute-scroll-into-view@npm:^3.0.2": + version: 3.0.3 + resolution: "compute-scroll-into-view@npm:3.0.3" + checksum: 7143869648d4de8ff2cb60eb8e96a21b47948c3210d15d1bfaa7e88de722c7f83f06676b97ebff94831dde0c03e42458ecfbde466747945187ee5c7667c68395 + languageName: node + linkType: hard + "concat-map@npm:0.0.1": version: 0.0.1 resolution: "concat-map@npm:0.0.1" @@ -27654,6 +27680,15 @@ __metadata: languageName: node linkType: hard +"scroll-into-view-if-needed@npm:^3.0.6": + version: 3.0.10 + resolution: "scroll-into-view-if-needed@npm:3.0.10" + dependencies: + compute-scroll-into-view: ^3.0.2 + checksum: eab326e527620883040e1937329bce28396ac67199098202fc785853b1576646ff1c987594f5630f78bfd84fda8486a793845c0f5c0b1ad70638c6d015578ebb + languageName: node + linkType: hard + "scuid@npm:^1.1.0": version: 1.1.0 resolution: "scuid@npm:1.1.0" @@ -28117,6 +28152,15 @@ __metadata: languageName: node linkType: hard +"smooth-scroll-into-view-if-needed@npm:^2.0.0": + version: 2.0.0 + resolution: "smooth-scroll-into-view-if-needed@npm:2.0.0" + dependencies: + scroll-into-view-if-needed: ^3.0.6 + checksum: 2a96df1d7e477eca30d7649e30cec85217084ad83a2cdf08559fac58a03121e07607e6977e480468d18cebe244cea6b7b4fd86de9bb2341a10b4be4a413cbef8 + languageName: node + linkType: hard + "snake-case@npm:^3.0.4": version: 3.0.4 resolution: "snake-case@npm:3.0.4"