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:
Saul-Mirone
2025-04-21 04:27:18 +00:00
parent 535b5ff7c2
commit 70ddae15ef
15 changed files with 800 additions and 0 deletions
+137
View File
@@ -0,0 +1,137 @@
import { BlockSuiteError } from '@blocksuite/global/exceptions';
import type { ExtensionType } from '@blocksuite/store';
import type { BaseExtensionProvider, Context, Empty } from './base-provider';
/**
* A manager class that handles the registration and configuration of extensions
* for different scopes. It manages extension providers and their instances,
* allowing for dynamic configuration and extension loading.
*
* @typeParam Scope - The type of scope identifiers used for categorizing extensions
*/
export class ExtensionManager<Scope extends string> {
/** @internal */
protected _extensions: Map<string, Set<ExtensionType>> = new Map();
/** @internal */
private readonly _providers: Set<typeof BaseExtensionProvider<Scope>>;
/** @internal */
private readonly _providerOptions: Map<
typeof BaseExtensionProvider<Scope>,
object
> = new Map();
/** @internal */
private readonly _providerInstances: Map<
typeof BaseExtensionProvider<Scope>,
BaseExtensionProvider<Scope>
> = new Map();
/**
* Creates a new ExtensionManager instance with the specified providers.
*
* @param providers - Array of extension provider classes to be managed
*/
constructor(providers: Array<typeof BaseExtensionProvider<Scope>>) {
this._providers = new Set(providers);
}
/** @internal */
private readonly _build = (scope: Scope) => {
const context = this._getContextByScope(scope);
this._providers.forEach(Provider => {
let instance: BaseExtensionProvider<Scope>;
if (this._providerInstances.has(Provider)) {
instance = this._providerInstances.get(Provider)!;
} else {
instance = new Provider();
this._providerInstances.set(Provider, instance);
}
instance.setup(context, this._providerOptions.get(Provider));
});
};
/** @internal */
private readonly _registerToScope = (
scope: Scope,
extensions: ExtensionType[] | ExtensionType
) => {
let extSet: Set<ExtensionType>;
if (!this._extensions.has(scope)) {
extSet = new Set();
} else {
extSet = this._extensions.get(scope)!;
}
const extensionsArray = Array.isArray(extensions)
? extensions
: [extensions];
extensionsArray.forEach(extension => {
extSet.add(extension);
});
this._extensions.set(scope, extSet);
};
/** @internal */
private readonly _getContextByScope = (scope: Scope): Context<Scope> => {
return {
scope,
register: (extensions: ExtensionType[] | ExtensionType) =>
this._registerToScope(scope, extensions),
};
};
/**
* Retrieves all extensions registered for a specific scope.
* If the scope hasn't been built yet, it triggers the build process.
*
* @param scope - The scope to retrieve extensions for
* @returns An array of extensions registered for the specified scope
* @throws {BlockSuiteError} If the scope is not found
*/
get(scope: Scope) {
if (!this._extensions.has(scope)) {
this._build(scope);
}
const extensionSet = this._extensions.get(scope);
if (!extensionSet) {
throw new BlockSuiteError(
BlockSuiteError.ErrorCode.ValueNotExists,
`Extension scope ${scope} not found`
);
}
return Array.from(extensionSet);
}
/**
* Configures a specific provider with new options.
* Can update existing configuration or remove it entirely.
* Triggers a rebuild of the provider instance when configuration changes.
*
* @typeParam T - The type of configuration options for the provider
* @param provider - The provider class to configure
* @param options - New configuration options or a function to update existing options
*/
configure<T extends Empty>(
provider: typeof BaseExtensionProvider<Scope, T>,
options: ((prev: T | undefined) => T | undefined) | T | undefined
) {
let config: T | undefined;
if (typeof options === 'function') {
const prev = this._providerOptions.get(provider);
config = (options as (prev: unknown) => T)(prev);
} else {
config = options;
}
if (config === undefined) {
this._providerOptions.delete(provider);
} else {
this._providerOptions.set(provider, config);
}
// If the config is changed, we need to rebuild the extension
this._providerInstances.delete(provider);
}
}