mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-29 16:19:43 +08:00
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');
```
This commit is contained in:
@@ -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<Option> {
|
||||
override name = 'ViewExt1';
|
||||
|
||||
override schema = z.object({
|
||||
foo: z.number(),
|
||||
bar: z.string(),
|
||||
});
|
||||
|
||||
override setup(context: ViewExtensionContext, option?: Option) {
|
||||
super.setup(context, option);
|
||||
if (context.scope === 'page') {
|
||||
setupOption(option);
|
||||
context.register(Ext1);
|
||||
}
|
||||
if (context.scope === 'edgeless') {
|
||||
setupOption(option);
|
||||
context.register(Ext2);
|
||||
}
|
||||
}
|
||||
}
|
||||
const manager = new ExtensionManager([ViewExt1]);
|
||||
|
||||
manager.configure(ViewExt1, { foo: 1, bar: '2' });
|
||||
manager.configure(ViewExt1, prev => {
|
||||
if (!prev) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
foo: prev.foo + 1,
|
||||
};
|
||||
});
|
||||
let viewExtensions = manager.get('page');
|
||||
expect(viewExtensions).toEqual([Ext1]);
|
||||
expect(setupOption).toHaveBeenCalledWith({ foo: 2, bar: '2' });
|
||||
|
||||
setupOption.mockClear();
|
||||
manager.configure(ViewExt1, undefined);
|
||||
viewExtensions = manager.get('edgeless');
|
||||
expect(viewExtensions).toEqual([Ext2]);
|
||||
expect(setupOption).toHaveBeenCalledWith(undefined);
|
||||
|
||||
viewExtensions = manager.get('page');
|
||||
expect(setupOption).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it('should extension manager be able to be injected', () => {
|
||||
class StoreExt1 extends StoreExtensionProvider {
|
||||
override name = 'StoreExt1';
|
||||
|
||||
override setup(context: StoreExtensionContext) {
|
||||
super.setup(context);
|
||||
context.register(Ext1);
|
||||
}
|
||||
}
|
||||
const manager = new StoreExtensionManager([StoreExt1]);
|
||||
const extensions = manager.get('store');
|
||||
const container = new Container();
|
||||
extensions.forEach(ext => {
|
||||
ext.setup(container);
|
||||
});
|
||||
const provider = container.provider();
|
||||
expect(provider.get(StoreExtensionManagerIdentifier)).toBe(manager);
|
||||
});
|
||||
Reference in New Issue
Block a user