snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped
This commit is contained in:
171
blocksuite/affine/ext-loader/README.md
Normal file
171
blocksuite/affine/ext-loader/README.md
Normal file
@@ -0,0 +1,171 @@
|
||||
# @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');
|
||||
```
|
||||
30
blocksuite/affine/ext-loader/package.json
Normal file
30
blocksuite/affine/ext-loader/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@blocksuite/affine-ext-loader",
|
||||
"description": "Extension loader for affine",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "yarn run -T tsc"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"keywords": [],
|
||||
"author": "toeverything",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.27.5"
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
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 { ViewExtensionManager } from '../view-manager';
|
||||
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';
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
setup1();
|
||||
}
|
||||
|
||||
override setup(context: ViewExtensionContext, option?: { foo: number }) {
|
||||
super.setup(context, option);
|
||||
if (context.scope === 'page') {
|
||||
context.register(Ext2);
|
||||
}
|
||||
if (context.scope === 'edgeless') {
|
||||
context.register(Ext3);
|
||||
}
|
||||
}
|
||||
}
|
||||
class ViewExt2 extends ViewExtensionProvider {
|
||||
override name = 'ViewExt2';
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
setup2();
|
||||
}
|
||||
|
||||
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 cache provider instances', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
it('should effect only run once', () => {
|
||||
const effect1 = vi.fn();
|
||||
const effect2 = vi.fn();
|
||||
class ViewExt1 extends ViewExtensionProvider {
|
||||
override name = 'ViewExt1';
|
||||
|
||||
override effect() {
|
||||
super.effect();
|
||||
effect1();
|
||||
}
|
||||
|
||||
override setup(context: ViewExtensionContext) {
|
||||
super.setup(context);
|
||||
context.register(Ext1);
|
||||
}
|
||||
}
|
||||
|
||||
class ViewExt2 extends ViewExtensionProvider {
|
||||
override name = 'ViewExt2';
|
||||
|
||||
override effect() {
|
||||
super.effect();
|
||||
effect2();
|
||||
}
|
||||
|
||||
override setup(context: ViewExtensionContext) {
|
||||
super.setup(context);
|
||||
context.register(Ext2);
|
||||
}
|
||||
}
|
||||
|
||||
const manager = new ViewExtensionManager([ViewExt1]);
|
||||
|
||||
expect(ViewExt1.effectRan).toBe(false);
|
||||
expect(ViewExt2.effectRan).toBe(false);
|
||||
|
||||
manager.get('page');
|
||||
|
||||
expect(ViewExt1.effectRan).toBe(true);
|
||||
expect(ViewExt2.effectRan).toBe(false);
|
||||
|
||||
expect(effect1).toHaveBeenCalledTimes(1);
|
||||
expect(effect2).toHaveBeenCalledTimes(0);
|
||||
|
||||
manager.get('edgeless');
|
||||
|
||||
expect(ViewExt1.effectRan).toBe(true);
|
||||
expect(ViewExt2.effectRan).toBe(false);
|
||||
|
||||
expect(effect1).toHaveBeenCalledTimes(1);
|
||||
expect(effect2).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
68
blocksuite/affine/ext-loader/src/base-provider.ts
Normal file
68
blocksuite/affine/ext-loader/src/base-provider.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
import { z, type ZodSchema } from 'zod';
|
||||
|
||||
/**
|
||||
* An empty object type used as a default for extension provider options
|
||||
* when no specific options are needed.
|
||||
*/
|
||||
export type Empty = {};
|
||||
|
||||
/**
|
||||
* Context object provided to extension providers during setup.
|
||||
* Contains the scope information and a registration function for extensions.
|
||||
*
|
||||
* @typeParam Scope - The type of scope identifiers used for categorizing extensions
|
||||
*/
|
||||
export type Context<Scope extends string> = {
|
||||
/** The scope this context is associated with */
|
||||
scope: Scope;
|
||||
/** Function to register one or more extensions */
|
||||
register(extensions: ExtensionType[] | ExtensionType): Context<Scope>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Base class for all extension providers.
|
||||
* Provides common functionality for managing extensions and validating options.
|
||||
*
|
||||
* @typeParam Scope - The type of scope identifiers used for categorizing extensions
|
||||
* @typeParam Options - The type of configuration options for the provider
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* 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
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class BaseExtensionProvider<
|
||||
Scope extends string,
|
||||
Options extends object = Empty,
|
||||
> {
|
||||
/** The name of the provider */
|
||||
name = 'BaseExtension';
|
||||
|
||||
/** Zod schema for validating provider options */
|
||||
schema: ZodSchema = z.object({});
|
||||
|
||||
/**
|
||||
* Sets up the provider with the given context and options.
|
||||
* Validates the options against the schema if provided.
|
||||
*
|
||||
* @param context - The context object containing scope and registration function
|
||||
* @param option - Optional configuration options for the provider
|
||||
*/
|
||||
setup(_context: Context<Scope>, option?: Options) {
|
||||
if (option) {
|
||||
this.schema.parse(option);
|
||||
}
|
||||
}
|
||||
}
|
||||
4
blocksuite/affine/ext-loader/src/index.ts
Normal file
4
blocksuite/affine/ext-loader/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './store-manager';
|
||||
export * from './store-provider';
|
||||
export * from './view-manager';
|
||||
export * from './view-provider';
|
||||
146
blocksuite/affine/ext-loader/src/manager.ts
Normal file
146
blocksuite/affine/ext-loader/src/manager.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
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> => {
|
||||
const context: Context<Scope> = {
|
||||
scope,
|
||||
register: (extensions: ExtensionType[] | ExtensionType) => {
|
||||
this._registerToScope(scope, extensions);
|
||||
return context;
|
||||
},
|
||||
};
|
||||
return context;
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves all extensions registered for a specific scope.
|
||||
* 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._extensions.delete(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
|
||||
) {
|
||||
const prev = this._providerOptions.get(provider);
|
||||
|
||||
let config: T | undefined;
|
||||
if (typeof options === 'function') {
|
||||
config = (options as (prev: unknown) => T)(prev);
|
||||
} else {
|
||||
config = options;
|
||||
}
|
||||
|
||||
if (prev === config) {
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
47
blocksuite/affine/ext-loader/src/store-manager.ts
Normal file
47
blocksuite/affine/ext-loader/src/store-manager.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { createIdentifier } from '@blocksuite/global/di';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
|
||||
import { ExtensionManager } from './manager';
|
||||
|
||||
/**
|
||||
* Identifier for the StoreExtensionManager that can be used for dependency injection.
|
||||
*/
|
||||
export const StoreExtensionManagerIdentifier =
|
||||
createIdentifier<StoreExtensionManager>('StoreExtensionManager');
|
||||
|
||||
/**
|
||||
* A specialized extension manager for store-related extensions.
|
||||
* Extends the base ExtensionManager to provide store-specific functionality.
|
||||
*
|
||||
* This manager is responsible for handling store-related extensions and ensuring
|
||||
* proper dependency injection setup for store components.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Create a store extension manager with providers
|
||||
* const manager = new StoreExtensionManager([MyStoreProvider]);
|
||||
*
|
||||
* // Configure provider options
|
||||
* manager.configure(MyStoreProvider, { option1: 'value' });
|
||||
*
|
||||
* // Get store extensions
|
||||
* const extensions = manager.get('store');
|
||||
* ```
|
||||
*/
|
||||
export class StoreExtensionManager extends ExtensionManager<'store'> {
|
||||
/**
|
||||
* Retrieves store extensions and adds self-registration functionality.
|
||||
*
|
||||
* @param scope - The scope of extensions to retrieve, must be 'store'
|
||||
* @returns An array of extensions including the self-registration extension
|
||||
*/
|
||||
override get(scope: 'store') {
|
||||
const extensions = super.get(scope);
|
||||
const selfExtension: ExtensionType = {
|
||||
setup: di => {
|
||||
di.addImpl(StoreExtensionManagerIdentifier, () => this);
|
||||
},
|
||||
};
|
||||
return extensions.concat(selfExtension);
|
||||
}
|
||||
}
|
||||
41
blocksuite/affine/ext-loader/src/store-provider.ts
Normal file
41
blocksuite/affine/ext-loader/src/store-provider.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
BaseExtensionProvider,
|
||||
type Context,
|
||||
type Empty,
|
||||
} from './base-provider';
|
||||
|
||||
/**
|
||||
* A specialized extension provider for store-related functionality.
|
||||
* Extends the base provider with store-specific scope and configuration.
|
||||
*
|
||||
* @typeParam Options - The type of configuration options for the store provider
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // 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]);
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class StoreExtensionProvider<
|
||||
Options extends object = Empty,
|
||||
> extends BaseExtensionProvider<'store', Options> {
|
||||
/** The name of the store extension provider */
|
||||
override name = 'StoreExtension';
|
||||
}
|
||||
|
||||
/**
|
||||
* Context type specifically for store-related extensions.
|
||||
* Provides type safety for store extension registration and setup.
|
||||
*/
|
||||
export type StoreExtensionContext = Context<'store'>;
|
||||
49
blocksuite/affine/ext-loader/src/view-manager.ts
Normal file
49
blocksuite/affine/ext-loader/src/view-manager.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { createIdentifier } from '@blocksuite/global/di';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
|
||||
import { ExtensionManager } from './manager';
|
||||
import type { ViewScope } from './view-provider';
|
||||
|
||||
/**
|
||||
* Identifier for the ViewExtensionManager that can be used for dependency injection.
|
||||
*/
|
||||
export const ViewExtensionManagerIdentifier =
|
||||
createIdentifier<ViewExtensionManager>('ViewExtensionManager');
|
||||
|
||||
/**
|
||||
* A specialized extension manager for view-related extensions.
|
||||
* Extends the base ExtensionManager to provide view-specific functionality.
|
||||
*
|
||||
* This manager is responsible for handling view-related extensions and ensuring
|
||||
* proper dependency injection setup for view components.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Create a view extension manager with providers
|
||||
* const manager = new ViewExtensionManager([MyViewProvider]);
|
||||
*
|
||||
* // Configure provider options
|
||||
* manager.configure(MyViewProvider, { option1: 'value' });
|
||||
*
|
||||
* // Get view extensions for a specific scope
|
||||
* const pageExtensions = manager.get('page');
|
||||
* const edgelessExtensions = manager.get('edgeless');
|
||||
* ```
|
||||
*/
|
||||
export class ViewExtensionManager extends ExtensionManager<ViewScope> {
|
||||
/**
|
||||
* Retrieves view extensions and adds self-registration functionality.
|
||||
*
|
||||
* @param scope - The scope of extensions to retrieve
|
||||
* @returns An array of extensions including the self-registration extension
|
||||
*/
|
||||
override get(scope: ViewScope) {
|
||||
const extensions = super.get(scope);
|
||||
const selfExtension: ExtensionType = {
|
||||
setup: di => {
|
||||
di.addImpl(ViewExtensionManagerIdentifier, () => this);
|
||||
},
|
||||
};
|
||||
return extensions.concat(selfExtension);
|
||||
}
|
||||
}
|
||||
129
blocksuite/affine/ext-loader/src/view-provider.ts
Normal file
129
blocksuite/affine/ext-loader/src/view-provider.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
BaseExtensionProvider,
|
||||
type Context,
|
||||
type Empty,
|
||||
} from './base-provider';
|
||||
|
||||
/**
|
||||
* Available view scopes for view-related extensions.
|
||||
* Defines the different types of views where extensions can be applied.
|
||||
*/
|
||||
export type ViewScope =
|
||||
| '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
|
||||
|
||||
/**
|
||||
* A specialized extension provider for view-related functionality.
|
||||
* Extends the base provider with view-specific scope and configuration.
|
||||
*
|
||||
* @typeParam Options - The type of configuration options for the view provider
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // 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');
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class ViewExtensionProvider<
|
||||
Options extends object = Empty,
|
||||
> extends BaseExtensionProvider<ViewScope, Options> {
|
||||
/** The name of the view extension provider */
|
||||
override name = 'ViewExtension';
|
||||
|
||||
/**
|
||||
* Static flag to ensure effect is only run once per provider class
|
||||
* @internal
|
||||
*/
|
||||
static effectRan = false;
|
||||
|
||||
/**
|
||||
* Override this method to implement one-time initialization logic for the provider.
|
||||
* This method will be called automatically during setup, but only once per provider class.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* override effect() {
|
||||
* super.effect();
|
||||
* // Register lit elements
|
||||
* registerLitElements();
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
effect(): void {}
|
||||
|
||||
/**
|
||||
* Check if the scope is edgeless
|
||||
* @param scope - The scope to check
|
||||
* @returns True if the scope is edgeless, false otherwise
|
||||
*/
|
||||
isEdgeless = (scope: ViewScope) => {
|
||||
return (
|
||||
scope === 'edgeless' ||
|
||||
scope === 'preview-edgeless' ||
|
||||
scope === 'mobile-edgeless'
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the scope is preview
|
||||
* @param scope - The scope to check
|
||||
* @returns True if the scope is preview, false otherwise
|
||||
*/
|
||||
isPreview = (scope: ViewScope) => {
|
||||
return scope === 'preview-page' || scope === 'preview-edgeless';
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the scope is mobile
|
||||
* @param scope - The scope to check
|
||||
* @returns True if the scope is mobile, false otherwise
|
||||
*/
|
||||
isMobile = (scope: ViewScope) => {
|
||||
return scope === 'mobile-page' || scope === 'mobile-edgeless';
|
||||
};
|
||||
|
||||
override setup(context: ViewExtensionContext, options?: Options) {
|
||||
super.setup(context, options);
|
||||
const constructor = this.constructor as typeof ViewExtensionProvider;
|
||||
if (!constructor.effectRan) {
|
||||
constructor.effectRan = true;
|
||||
this.effect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Context type specifically for view-related extensions.
|
||||
* Provides type safety for view extension registration and setup.
|
||||
*/
|
||||
export type ViewExtensionContext = Context<ViewScope>;
|
||||
13
blocksuite/affine/ext-loader/tsconfig.json
Normal file
13
blocksuite/affine/ext-loader/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"include": ["./src"],
|
||||
"references": [
|
||||
{ "path": "../../framework/global" },
|
||||
{ "path": "../../framework/store" }
|
||||
]
|
||||
}
|
||||
28
blocksuite/affine/ext-loader/vitest.config.ts
Normal file
28
blocksuite/affine/ext-loader/vitest.config.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
esbuild: {
|
||||
target: 'es2018',
|
||||
},
|
||||
test: {
|
||||
include: ['src/__tests__/**/*.unit.spec.ts'],
|
||||
testTimeout: 500,
|
||||
coverage: {
|
||||
provider: 'istanbul',
|
||||
reporter: ['lcov'],
|
||||
reportsDirectory: '../../../.coverage/ext-loader',
|
||||
},
|
||||
/**
|
||||
* Custom handler for console.log in tests.
|
||||
*
|
||||
* Return `false` to ignore the log.
|
||||
*/
|
||||
onConsoleLog(log, type) {
|
||||
if (log.includes('https://lit.dev/msg/dev-mode')) {
|
||||
return false;
|
||||
}
|
||||
console.warn(`Unexpected ${type} log`, log);
|
||||
throw new Error(log);
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user