From 70ddae15ef98036e015a4944b74c5ef48a642ee0 Mon Sep 17 00:00:00 2001 From: Saul-Mirone Date: Mon, 21 Apr 2025 04:27:18 +0000 Subject: [PATCH] feat(editor): affine extension provider and manager (#11822) Closes: BS-3186 # @blocksuite/affine-ext-loader Blocksuite extension loader system for AFFiNE, providing a structured way to manage and load extensions in different contexts. ## Usage ### Basic Extension Provider ```typescript import { BaseExtensionProvider } from '@blocksuite/affine-ext-loader'; import { z } from 'zod'; // Create a custom provider with options class MyProvider extends BaseExtensionProvider<'my-scope', { enabled: boolean }> { name = 'MyProvider'; schema = z.object({ enabled: z.boolean(), }); setup(context: Context<'my-scope'>, options?: { enabled: boolean }) { super.setup(context, options); // Custom setup logic } } ``` ### Store Extensions ```typescript import { StoreExtensionProvider, StoreExtensionManager } from '@blocksuite/affine-ext-loader'; import { z } from 'zod'; // Create a store provider with custom options class MyStoreProvider extends StoreExtensionProvider<{ cacheSize: number }> { override name = 'MyStoreProvider'; override schema = z.object({ cacheSize: z.number().min(0), }); override setup(context: StoreExtensionContext, options?: { cacheSize: number }) { super.setup(context, options); context.register([Ext1, Ext2, Ext3]); } } // Create and use the store extension manager const manager = new StoreExtensionManager([MyStoreProvider]); manager.configure(MyStoreProvider, { cacheSize: 100 }); const extensions = manager.get('store'); ``` ### View Extensions ```typescript import { ViewExtensionProvider, ViewExtensionManager } from '@blocksuite/affine-ext-loader'; import { z } from 'zod'; // Create a view provider with custom options class MyViewProvider extends ViewExtensionProvider<{ theme: string }> { override name = 'MyViewProvider'; override schema = z.object({ theme: z.enum(['light', 'dark']), }); override setup(context: ViewExtensionContext, options?: { theme: string }) { super.setup(context, options); context.register([CommonExt]); if (context.scope === 'page') { context.register([PageExt]); } else if (context.scope === 'edgeless') { context.register([EdgelessExt]); } if (options?.theme === 'dark') { context.register([DarkModeExt]); } } // Override effect to run one-time initialization logic override effect() { // This will only run once per provider class console.log('Initializing MyViewProvider'); // Register lit elements this.registerLitElements(); } } // Create and use the view extension manager const manager = new ViewExtensionManager([MyViewProvider]); manager.configure(MyViewProvider, { theme: 'dark' }); // Get extensions for different view scopes const pageExtensions = manager.get('page'); const edgelessExtensions = manager.get('edgeless'); ``` ### One-time Initialization with Effect View extensions support one-time initialization through the `effect` method. This method is called automatically during setup, but only once per provider class. It's useful for: - Initializing global state - Registering lit elements - Setting up shared resources ```typescript class MyViewProvider extends ViewExtensionProvider { override effect() { // This will only run once, even if multiple instances are created initializeGlobalState(); registerLitElements(); setupGlobalEventListeners(); } } ``` ### Available View Scopes The view extension system supports the following scopes: - `page` - Standard page view - `edgeless` - Edgeless (whiteboard) view - `preview-page` - Page preview view - `preview-edgeless` - Edgeless preview view - `mobile-page` - Mobile page view - `mobile-edgeless` - Mobile edgeless view ### Extension Configuration Extensions can be configured using the `configure` method: ```typescript // Set configuration directly manager.configure(MyProvider, { enabled: true }); // Update configuration using a function manager.configure(MyProvider, prev => { if (!prev) return prev; return { ...prev, enabled: !prev.enabled, }; }); // Remove configuration manager.configure(MyProvider, undefined); ``` ### Dependency Injection Both store and view extension managers support dependency injection: ```typescript // Access the manager through the di container const viewManager = std.get(ViewExtensionManagerIdentifier); const pagePreviewExtension = viewManager.get('preview-page'); ``` --- blocksuite/affine/ext-loader/README.md | 132 ++++++++++++++ blocksuite/affine/ext-loader/package.json | 30 ++++ .../src/__tests__/ext-loader.unit.spec.ts | 167 ++++++++++++++++++ .../affine/ext-loader/src/base-provider.ts | 69 ++++++++ blocksuite/affine/ext-loader/src/index.ts | 4 + blocksuite/affine/ext-loader/src/manager.ts | 137 ++++++++++++++ .../affine/ext-loader/src/store-manager.ts | 47 +++++ .../affine/ext-loader/src/store-provider.ts | 41 +++++ .../affine/ext-loader/src/view-manager.ts | 49 +++++ .../affine/ext-loader/src/view-provider.ts | 62 +++++++ blocksuite/affine/ext-loader/tsconfig.json | 13 ++ blocksuite/affine/ext-loader/vitest.config.ts | 28 +++ tools/utils/src/workspace.gen.ts | 9 + tsconfig.json | 1 + yarn.lock | 11 ++ 15 files changed, 800 insertions(+) create mode 100644 blocksuite/affine/ext-loader/README.md create mode 100644 blocksuite/affine/ext-loader/package.json create mode 100644 blocksuite/affine/ext-loader/src/__tests__/ext-loader.unit.spec.ts create mode 100644 blocksuite/affine/ext-loader/src/base-provider.ts create mode 100644 blocksuite/affine/ext-loader/src/index.ts create mode 100644 blocksuite/affine/ext-loader/src/manager.ts create mode 100644 blocksuite/affine/ext-loader/src/store-manager.ts create mode 100644 blocksuite/affine/ext-loader/src/store-provider.ts create mode 100644 blocksuite/affine/ext-loader/src/view-manager.ts create mode 100644 blocksuite/affine/ext-loader/src/view-provider.ts create mode 100644 blocksuite/affine/ext-loader/tsconfig.json create mode 100644 blocksuite/affine/ext-loader/vitest.config.ts diff --git a/blocksuite/affine/ext-loader/README.md b/blocksuite/affine/ext-loader/README.md new file mode 100644 index 000000000..3712edff5 --- /dev/null +++ b/blocksuite/affine/ext-loader/README.md @@ -0,0 +1,132 @@ +# @blocksuite/affine-ext-loader + +Blocksuite extension loader system for AFFiNE, providing a structured way to manage and load extensions in different contexts. + +## Usage + +### Basic Extension Provider + +```typescript +import { BaseExtensionProvider } from '@blocksuite/affine-ext-loader'; +import { z } from 'zod'; + +// Create a custom provider with options +class MyProvider extends BaseExtensionProvider<'my-scope', { enabled: boolean }> { + name = 'MyProvider'; + + schema = z.object({ + enabled: z.boolean(), + }); + + setup(context: Context<'my-scope'>, options?: { enabled: boolean }) { + super.setup(context, options); + // Custom setup logic + } +} +``` + +### Store Extensions + +```typescript +import { StoreExtensionProvider, StoreExtensionManager } from '@blocksuite/affine-ext-loader'; +import { z } from 'zod'; + +// Create a store provider with custom options +class MyStoreProvider extends StoreExtensionProvider<{ cacheSize: number }> { + override name = 'MyStoreProvider'; + + override schema = z.object({ + cacheSize: z.number().min(0), + }); + + override setup(context: StoreExtensionContext, options?: { cacheSize: number }) { + super.setup(context, options); + context.register([Ext1, Ext2, Ext3]); + } +} + +// Create and use the store extension manager +const manager = new StoreExtensionManager([MyStoreProvider]); +manager.configure(MyStoreProvider, { cacheSize: 100 }); +const extensions = manager.get('store'); +``` + +### View Extensions + +```typescript +import { ViewExtensionProvider, ViewExtensionManager } from '@blocksuite/affine-ext-loader'; +import { z } from 'zod'; + +// Create a view provider with custom options +class MyViewProvider extends ViewExtensionProvider<{ theme: string }> { + override name = 'MyViewProvider'; + + override schema = z.object({ + theme: z.enum(['light', 'dark']), + }); + + override setup(context: ViewExtensionContext, options?: { theme: string }) { + super.setup(context, options); + + context.register([CommonExt]); + if (context.scope === 'page') { + context.register([PageExt]); + } else if (context.scope === 'edgeless') { + context.register([EdgelessExt]); + } + if (options?.theme === 'dark') { + context.register([DarkModeExt]); + } + } +} + +// Create and use the view extension manager +const manager = new ViewExtensionManager([MyViewProvider]); +manager.configure(MyViewProvider, { theme: 'dark' }); + +// Get extensions for different view scopes +const pageExtensions = manager.get('page'); +const edgelessExtensions = manager.get('edgeless'); +``` + +### Available View Scopes + +The view extension system supports the following scopes: + +- `page` - Standard page view +- `edgeless` - Edgeless (whiteboard) view +- `preview-page` - Page preview view +- `preview-edgeless` - Edgeless preview view +- `mobile-page` - Mobile page view +- `mobile-edgeless` - Mobile edgeless view + +### Extension Configuration + +Extensions can be configured using the `configure` method: + +```typescript +// Set configuration directly +manager.configure(MyProvider, { enabled: true }); + +// Update configuration using a function +manager.configure(MyProvider, prev => { + if (!prev) return prev; + return { + ...prev, + enabled: !prev.enabled, + }; +}); + +// Remove configuration +manager.configure(MyProvider, undefined); +``` + +### Dependency Injection + +Both store and view extension managers support dependency injection: + +```typescript +// Access the manager through the di container +const viewManager = std.get(ViewExtensionManagerIdentifier); +const pagePreviewExtension = viewManager.get('preview-page'); +``` diff --git a/blocksuite/affine/ext-loader/package.json b/blocksuite/affine/ext-loader/package.json new file mode 100644 index 000000000..67606d9ee --- /dev/null +++ b/blocksuite/affine/ext-loader/package.json @@ -0,0 +1,30 @@ +{ + "name": "@blocksuite/affine-ext-loader", + "description": "Extension loader for affine", + "type": "module", + "scripts": { + "build": "tsc" + }, + "sideEffects": false, + "keywords": [], + "author": "toeverything", + "license": "MIT", + "dependencies": { + "@blocksuite/global": "workspace:*", + "@blocksuite/store": "workspace:*", + "zod": "^3.23.8" + }, + "devDependencies": { + "vitest": "3.1.1" + }, + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "src", + "dist", + "!src/__tests__", + "!dist/__tests__" + ], + "version": "0.21.0" +} diff --git a/blocksuite/affine/ext-loader/src/__tests__/ext-loader.unit.spec.ts b/blocksuite/affine/ext-loader/src/__tests__/ext-loader.unit.spec.ts new file mode 100644 index 000000000..207a01fea --- /dev/null +++ b/blocksuite/affine/ext-loader/src/__tests__/ext-loader.unit.spec.ts @@ -0,0 +1,167 @@ +import { Container } from '@blocksuite/global/di'; +import { type ExtensionType } from '@blocksuite/store'; +import { describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; + +import { ExtensionManager } from '../manager'; +import { + StoreExtensionManager, + StoreExtensionManagerIdentifier, +} from '../store-manager'; +import { + type StoreExtensionContext, + StoreExtensionProvider, +} from '../store-provider'; +import { + type ViewExtensionContext, + ViewExtensionProvider, +} from '../view-provider'; + +export const Ext1: ExtensionType = { + setup: () => {}, +}; +export const Ext2: ExtensionType = { + setup: () => {}, +}; +export const Ext3: ExtensionType = { + setup: () => {}, +}; +export const Ext4: ExtensionType = { + setup: () => {}, +}; +export const Ext5: ExtensionType = { + setup: () => {}, +}; + +it('should be able to load extensions', () => { + class StoreExt1 extends StoreExtensionProvider { + override name = 'StoreExt1'; + + override setup(context: StoreExtensionContext) { + super.setup(context); + context.register(Ext1); + } + } + const manager = new ExtensionManager([StoreExt1]); + const storeExtensions = manager.get('store'); + expect(storeExtensions).toEqual([Ext1]); +}); + +describe('multiple scopes', () => { + const setup1 = vi.fn(); + const setup2 = vi.fn(); + class ViewExt1 extends ViewExtensionProvider { + override name = 'ViewExt1'; + + override setup(context: ViewExtensionContext) { + super.setup(context); + if (context.scope === 'page') { + setup1(); + context.register(Ext2); + } + if (context.scope === 'edgeless') { + setup2(); + context.register(Ext3); + } + } + } + class ViewExt2 extends ViewExtensionProvider { + override name = 'ViewExt2'; + + override setup(context: ViewExtensionContext) { + super.setup(context); + if (context.scope === 'page') { + context.register(Ext4); + } + if (context.scope === 'edgeless') { + context.register(Ext5); + } + } + } + const manager = new ExtensionManager([ViewExt1, ViewExt2]); + const pageExtensions = manager.get('page'); + const edgelessExtensions = manager.get('edgeless'); + it('should be able to load extensions from different scopes', () => { + expect(pageExtensions).toEqual([Ext2, Ext4]); + expect(edgelessExtensions).toEqual([Ext3, Ext5]); + }); + + it('should setup be cached', () => { + manager.get('page'); + manager.get('edgeless'); + expect(setup1).toHaveBeenCalledTimes(1); + expect(setup2).toHaveBeenCalledTimes(1); + manager.get('page'); + manager.get('edgeless'); + expect(setup1).toHaveBeenCalledTimes(1); + expect(setup2).toHaveBeenCalledTimes(1); + }); +}); + +it('should be able to validate schema', () => { + type Option = { foo: number; bar: string }; + const setupOption = vi.fn(); + class ViewExt1 extends ViewExtensionProvider