diff --git a/blocksuite/affine/ext-loader/README.md b/blocksuite/affine/ext-loader/README.md new file mode 100644 index 0000000000..3712edff58 --- /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 0000000000..67606d9ee0 --- /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 0000000000..207a01feaf --- /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