Files
AFFiNE-Mirror/blocksuite/framework/block-std/src/command/manager.ts
2024-12-20 15:38:06 +08:00

383 lines
9.7 KiB
TypeScript

import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { LifeCycleWatcher } from '../extension/index.js';
import { CommandIdentifier } from '../identifier.js';
import { cmdSymbol } from './consts.js';
import type {
Chain,
Command,
ExecCommandResult,
IfAllKeysOptional,
InDataOfCommand,
InitCommandCtx,
} from './types.js';
/**
* Command manager to manage all commands
*
* Commands are functions that take a context and a next function as arguments
*
* ```ts
* const myCommand: Command<'count', 'count'> = (ctx, next) => {
* const count = ctx.count || 0;
*
* const success = someOperation();
* if (success) {
* return next({ count: count + 1 });
* }
* // if the command is not successful, you can return without calling next
* return;
* ```
*
* You should always add the command to the global interface `BlockSuite.Commands`
* ```ts
* declare global {
* namespace BlockSuite {
* interface Commands {
* 'myCommand': typeof myCommand
* }
* }
* }
* ```
*
* Command input and output data can be defined in the `Command` type
*
* ```ts
* // input: ctx.firstName, ctx.lastName
* // output: ctx.fullName
* const myCommand: Command<'firstName' | 'lastName', 'fullName'> = (ctx, next) => {
* const { firstName, lastName } = ctx;
* const fullName = `${firstName} ${lastName}`;
* return next({ fullName });
* }
*
* declare global {
* namespace BlockSuite {
* interface CommandContext {
* // All command input and output data should be defined here
* // The keys should be optional
* firstName?: string;
* lastName?: string;
* fullName?: string;
* }
* }
* }
*
* ```
*
*
* ---
*
* Commands can be run in two ways:
*
* 1. Using `exec` method
* `exec` is used to run a single command
* ```ts
* const { success, ...data } = commandManager.exec('myCommand', payload);
* ```
*
* 2. Using `chain` method
* `chain` is used to run a series of commands
* ```ts
* const chain = commandManager.chain();
* const [result, data] = chain
* .myCommand1()
* .myCommand2(payload)
* .run();
* ```
*
* ---
*
* Command chains will stop running if a command is not successful
*
* ```ts
* const chain = commandManager.chain();
* const [result, data] = chain
* .myCommand1() <-- if this fail
* .myCommand2(payload) <- this won't run
* .run();
*
* result <- result will be `false`
* ```
*
* You can use `try` to run a series of commands and if one of them is successful, it will continue to the next command
* ```ts
* const chain = commandManager.chain();
* const [result, data] = chain
* .try(chain => [
* chain.myCommand1(), <- if this fail
* chain.myCommand2(), <- this will run, if this success
* chain.myCommand3(), <- this won't run
* ])
* .run();
* ```
*
* The `tryAll` method is similar to `try`, but it will run all commands even if one of them is successful
* ```ts
* const chain = commandManager.chain();
* const [result, data] = chain
* .try(chain => [
* chain.myCommand1(), <- if this success
* chain.myCommand2(), <- this will also run
* chain.myCommand3(), <- so will this
* ])
* .run();
* ```
*
*/
export class CommandManager extends LifeCycleWatcher {
static override readonly key = 'commandManager';
private _commands = new Map<string, Command>();
private _createChain = (
methods: Record<BlockSuite.CommandName, unknown>,
_cmds: Command[]
): Chain => {
const getCommandCtx = this._getCommandCtx;
const createChain = this._createChain;
const chain = this.chain;
return {
[cmdSymbol]: _cmds,
run: function (this: Chain) {
let ctx = getCommandCtx();
let success = false;
try {
const cmds = this[cmdSymbol];
ctx = runCmds(ctx as BlockSuite.CommandContext, [
...cmds,
(_, next) => {
success = true;
next();
},
]);
} catch (err) {
console.error(err);
}
return [success, ctx];
},
with: function (this: Chain, value) {
const cmds = this[cmdSymbol];
return createChain(methods, [
...cmds,
(_, next) => next(value),
]) as never;
},
inline: function (this: Chain, command) {
const cmds = this[cmdSymbol];
return createChain(methods, [...cmds, command]) as never;
},
try: function (this: Chain, fn) {
const cmds = this[cmdSymbol];
return createChain(methods, [
...cmds,
(beforeCtx, next) => {
let ctx = beforeCtx;
const chains = fn(chain());
chains.some(chain => {
// inject ctx in the beginning
chain[cmdSymbol] = [
(_, next) => {
next(ctx);
},
...chain[cmdSymbol],
];
const [success] = chain
.inline((branchCtx, next) => {
ctx = { ...ctx, ...branchCtx };
next();
})
.run();
if (success) {
next(ctx);
return true;
}
return false;
});
},
]) as never;
},
tryAll: function (this: Chain, fn) {
const cmds = this[cmdSymbol];
return createChain(methods, [
...cmds,
(beforeCtx, next) => {
let ctx = beforeCtx;
const chains = fn(chain());
let allFail = true;
chains.forEach(chain => {
// inject ctx in the beginning
chain[cmdSymbol] = [
(_, next) => {
next(ctx);
},
...chain[cmdSymbol],
];
const [success] = chain
.inline((branchCtx, next) => {
ctx = { ...ctx, ...branchCtx };
next();
})
.run();
if (success) {
allFail = false;
}
});
if (!allFail) {
next(ctx);
}
},
]) as never;
},
...methods,
} as Chain;
};
private _getCommandCtx = (): InitCommandCtx => {
return {
std: this.std,
};
};
/**
* Create a chain to run a series of commands
* ```ts
* const chain = commandManager.chain();
* const [result, data] = chain
* .myCommand1()
* .myCommand2(payload)
* .run();
* ```
* @returns [success, data] - success is a boolean to indicate if the chain is successful,
* data is the final context after running the chain
*/
chain = (): Chain<InitCommandCtx> => {
const methods = {} as Record<
string,
(data: Record<string, unknown>) => Chain
>;
const createChain = this._createChain;
for (const [name, command] of this._commands.entries()) {
methods[name] = function (
this: { [cmdSymbol]: Command[] },
data: Record<string, unknown>
) {
const cmds = this[cmdSymbol];
return createChain(methods, [
...cmds,
(ctx, next) => command({ ...ctx, ...data }, next),
]);
};
}
return createChain(methods, []) as never;
};
/**
* Register a command to the command manager
* @param name
* @param command
* Make sure to also add the command to the global interface `BlockSuite.Commands`
* ```ts
* const myCommand: Command = (ctx, next) => {
* // do something
* }
*
* declare global {
* namespace BlockSuite {
* interface Commands {
* 'myCommand': typeof myCommand
* }
* }
* }
* ```
*/
add<N extends BlockSuite.CommandName>(
name: N,
command: BlockSuite.Commands[N]
): CommandManager;
add(name: string, command: Command) {
this._commands.set(name, command);
return this;
}
override created() {
const add = this.add.bind(this);
this.std.provider.getAll(CommandIdentifier).forEach((command, key) => {
add(key as keyof BlockSuite.Commands, command);
});
}
/**
* Execute a registered command by name
* @param command
* @param payloads
* ```ts
* const { success, ...data } = commandManager.exec('myCommand', { data: 'data' });
* ```
* @returns { success, ...data } - success is a boolean to indicate if the command is successful,
* data is the final context after running the command
*/
exec<K extends keyof BlockSuite.Commands>(
command: K,
...payloads: IfAllKeysOptional<
Omit<InDataOfCommand<BlockSuite.Commands[K]>, keyof InitCommandCtx>,
[
inData: void | Omit<
InDataOfCommand<BlockSuite.Commands[K]>,
keyof InitCommandCtx
>,
],
[
inData: Omit<
InDataOfCommand<BlockSuite.Commands[K]>,
keyof InitCommandCtx
>,
]
>
): ExecCommandResult<K> & { success: boolean } {
const cmdFunc = this._commands.get(command);
if (!cmdFunc) {
throw new BlockSuiteError(
ErrorCode.CommandError,
`The command "${command}" not found`
);
}
const inData = payloads[0];
const ctx = {
...this._getCommandCtx(),
...inData,
};
let execResult = {
success: false,
} as ExecCommandResult<K> & { success: boolean };
cmdFunc(ctx, result => {
// @ts-expect-error FIXME: ts error
execResult = { ...result, success: true };
});
return execResult;
}
}
function runCmds(ctx: BlockSuite.CommandContext, [cmd, ...rest]: Command[]) {
let _ctx = ctx;
if (cmd) {
cmd(ctx, data => {
_ctx = runCmds({ ...ctx, ...data }, rest);
});
}
return _ctx;
}