This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
const { defineFlatConfig } = require('@flowgram.ai/eslint-config');
|
||||
|
||||
module.exports = defineFlatConfig({
|
||||
preset: 'web',
|
||||
packageRoot: __dirname,
|
||||
rules: {
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
{
|
||||
selector: 'ExportAllDeclaration',
|
||||
message:
|
||||
'Do not re-export everything from another modules, you should explicitly specify the members to be exported.',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "@flowgram.ai/command",
|
||||
"version": "0.1.8",
|
||||
"homepage": "https://flowgram.ai/",
|
||||
"repository": "https://github.com/bytedance/flowgram.ai",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/esm/index.js",
|
||||
"require": "./dist/index.js"
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/esm/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "npm run build:fast -- --dts-resolve",
|
||||
"build:fast": "tsup src/index.ts --format cjs,esm --sourcemap --legacy-output",
|
||||
"build:watch": "npm run build:fast -- --dts-resolve",
|
||||
"clean": "rimraf dist",
|
||||
"ts-check": "tsc --noEmit",
|
||||
"watch": "npm run build:fast -- --dts-resolve --watch --ignore-watch dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@flowgram.ai/utils": "workspace:*",
|
||||
"inversify": "^6.0.1",
|
||||
"reflect-metadata": "~0.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@flowgram.ai/eslint-config": "workspace:*",
|
||||
"@flowgram.ai/ts-config": "workspace:*",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^18",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"eslint": "^9.0.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"tsup": "^8.0.1",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
"react-dom": ">=16.8"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { ContainerModule } from 'inversify';
|
||||
import { bindContributionProvider } from '@flowgram.ai/utils';
|
||||
|
||||
import { CommandService } from './command-service';
|
||||
import { CommandRegistry, CommandRegistryFactory, CommandContribution } from './command';
|
||||
|
||||
export const CommandContainerModule = new ContainerModule(bind => {
|
||||
bindContributionProvider(bind, CommandContribution);
|
||||
bind(CommandRegistry).toSelf().inSingletonScope();
|
||||
bind(CommandService).toService(CommandRegistry);
|
||||
bind(CommandRegistryFactory).toFactory(ctx => () => ctx.container.get(CommandRegistry));
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { type Disposable, type Event } from '@flowgram.ai/utils';
|
||||
|
||||
import { type CommandEvent } from './command';
|
||||
|
||||
export const CommandService = Symbol('CommandService');
|
||||
|
||||
/**
|
||||
* command service 执行接口
|
||||
*/
|
||||
export interface CommandService extends Disposable {
|
||||
/**
|
||||
* command 事件执行前触发事件
|
||||
*/
|
||||
readonly onWillExecuteCommand: Event<CommandEvent>;
|
||||
/**
|
||||
* command 事件执行完成后触发
|
||||
*/
|
||||
readonly onDidExecuteCommand: Event<CommandEvent>;
|
||||
|
||||
/**
|
||||
* 执行 command
|
||||
*/
|
||||
executeCommand<T>(command: string, ...args: any[]): Promise<T | undefined>;
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { injectable, multiInject, optional } from 'inversify';
|
||||
import { Disposable, DisposableCollection, Emitter } from '@flowgram.ai/utils';
|
||||
|
||||
import { type CommandService } from './command-service';
|
||||
|
||||
export interface Command {
|
||||
/**
|
||||
* id,唯一 key
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* 展示用 label
|
||||
*/
|
||||
label?: string;
|
||||
/**
|
||||
* 在一些明确的场景下,部分只展示简短的 label 即可
|
||||
*/
|
||||
shortLabel?: string;
|
||||
|
||||
/**
|
||||
* 展示用 command icon
|
||||
*/
|
||||
icon?: string | React.ReactNode | ((props: any) => React.ReactNode);
|
||||
/**
|
||||
* 暂不使用
|
||||
*/
|
||||
category?: string;
|
||||
}
|
||||
export namespace Command {
|
||||
export enum Default {
|
||||
ZOOM_IN = 'ZOOM_IN',
|
||||
ZOOM_OUT = 'ZOOM_OUT',
|
||||
DELETE = 'DELETE',
|
||||
COPY = 'COPY',
|
||||
PASTE = 'PASTE',
|
||||
UNDO = 'UNDO',
|
||||
REDO = 'REDO',
|
||||
VIEW_CLOSE_ALL_WIDGET = 'view.closeAllWidget',
|
||||
VIEW_CLOSE_CURRENT_WIDGET = 'view.closeCurrentWidget',
|
||||
VIEW_REOPEN_LAST_WIDGET = 'view.reopenLastWidget',
|
||||
VIEW_CLOSE_OTHER_WIDGET = 'view.closeOtherWidget',
|
||||
VIEW_CLOSE_BOTTOM_PANEL = 'view.closeBottomPannel',
|
||||
VIEW_OPEN_NEXT_TAB = 'view.openNextTab',
|
||||
VIEW_OEPN_LAST_TAB = 'view.openLastTab',
|
||||
VIEW_FULL_SCREEN = 'view.fullScreen',
|
||||
VIEW_SAVING_WIDGET_CLOSE_CONFIRM = 'view.savingWidgetCloseConfirm',
|
||||
VIEW_SHORTCUTS = 'view.shortcuts',
|
||||
VIEW_PREFERENCES = 'view.preferences',
|
||||
VIEW_LOG = 'view.log',
|
||||
VIEW_PROBLEMS = 'view.problems',
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否是 command
|
||||
*/
|
||||
export function is(arg: Command | any): arg is Command {
|
||||
return !!arg && arg === Object(arg) && 'id' in arg;
|
||||
}
|
||||
}
|
||||
|
||||
export interface CommandHandler {
|
||||
/**
|
||||
* handler 执行函数
|
||||
*/
|
||||
execute(...args: any[]): any;
|
||||
|
||||
/**
|
||||
* 该 handler 是否可以执行
|
||||
*/
|
||||
isEnabled?(...args: any[]): boolean;
|
||||
|
||||
/**
|
||||
* 预留 contextMenu 用,该 handler 是否可见
|
||||
*/
|
||||
isVisible?(...args: any[]): boolean;
|
||||
|
||||
/**
|
||||
* 预留 contextMenu 用,该 handler 是否可以触发
|
||||
*/
|
||||
isToggled?(...args: any[]): boolean;
|
||||
}
|
||||
|
||||
export interface CommandEvent {
|
||||
/**
|
||||
* commandId
|
||||
*/
|
||||
commandId: string;
|
||||
/**
|
||||
* 参数
|
||||
*/
|
||||
args: any[];
|
||||
}
|
||||
export const CommandContribution = Symbol('CommandContribution');
|
||||
|
||||
export interface CommandContribution {
|
||||
/**
|
||||
* 注册 command
|
||||
*/
|
||||
registerCommands(commands: CommandService): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前正在运行的 command
|
||||
*/
|
||||
interface CommandExecuting {
|
||||
/**
|
||||
* commandid
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* 参数
|
||||
*/
|
||||
args: any[];
|
||||
/**
|
||||
* 正在进行的 promise
|
||||
*/
|
||||
promise?: Promise<any>;
|
||||
}
|
||||
|
||||
namespace CommandExecuting {
|
||||
/**
|
||||
* 获取正在运行的 command 单个实例
|
||||
*/
|
||||
export function findSimple(
|
||||
arrs: Set<CommandExecuting>,
|
||||
newCmd: CommandExecuting,
|
||||
): CommandExecuting | undefined {
|
||||
for (const item of arrs.values()) {
|
||||
if (
|
||||
item.id === newCmd.id &&
|
||||
item.args.length === newCmd.args.length &&
|
||||
item.args.every((arg, index) => (newCmd as any)[index] === arg)
|
||||
) {
|
||||
return item;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const CommandRegistryFactory = 'CommandRegistryFactory';
|
||||
|
||||
@injectable()
|
||||
export class CommandRegistry implements CommandService {
|
||||
protected readonly _handlers: { [id: string]: CommandHandler[] } = {};
|
||||
|
||||
protected readonly _commands: { [id: string]: Command } = {};
|
||||
|
||||
protected readonly _commandExecutings = new Set<CommandExecuting>();
|
||||
|
||||
protected readonly toUnregisterCommands = new Map<string, Disposable>();
|
||||
|
||||
protected readonly onDidExecuteCommandEmitter = new Emitter<CommandEvent>();
|
||||
|
||||
readonly onDidExecuteCommand = this.onDidExecuteCommandEmitter.event;
|
||||
|
||||
protected readonly onWillExecuteCommandEmitter = new Emitter<CommandEvent>();
|
||||
|
||||
readonly onWillExecuteCommand = this.onWillExecuteCommandEmitter.event;
|
||||
|
||||
@multiInject(CommandContribution)
|
||||
@optional()
|
||||
protected readonly contributions: CommandContribution[];
|
||||
|
||||
init() {
|
||||
for (const contrib of this.contributions) {
|
||||
contrib.registerCommands(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前所有 command
|
||||
*/
|
||||
get commands(): Command[] {
|
||||
const commands: Command[] = [];
|
||||
for (const id of this.commandIds) {
|
||||
const cmd = this.getCommand(id);
|
||||
if (cmd) {
|
||||
commands.push(cmd);
|
||||
}
|
||||
}
|
||||
return commands;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前所有 commandid
|
||||
*/
|
||||
get commandIds(): string[] {
|
||||
return Object.keys(this._commands);
|
||||
}
|
||||
|
||||
/**
|
||||
* registerCommand
|
||||
*/
|
||||
registerCommand(id: string, handler?: CommandHandler): Disposable;
|
||||
|
||||
registerCommand(command: Command, handler?: CommandHandler): Disposable;
|
||||
|
||||
registerCommand(commandOrId: string | Command, handler?: CommandHandler): Disposable {
|
||||
const command: Command = typeof commandOrId === 'string' ? { id: commandOrId } : commandOrId;
|
||||
|
||||
if (this._commands[command.id]) {
|
||||
console.warn(`A command ${command.id} is already registered.`);
|
||||
return Disposable.NULL;
|
||||
}
|
||||
const toDispose = new DisposableCollection(this.doRegisterCommand(command));
|
||||
if (handler) {
|
||||
toDispose.push(this.registerHandler(command.id, handler));
|
||||
}
|
||||
this.toUnregisterCommands.set(command.id, toDispose);
|
||||
toDispose.push(Disposable.create(() => this.toUnregisterCommands.delete(command.id)));
|
||||
return toDispose;
|
||||
}
|
||||
|
||||
/**
|
||||
* unregisterCommand
|
||||
*/
|
||||
unregisterCommand(command: Command): void;
|
||||
|
||||
unregisterCommand(id: string): void;
|
||||
|
||||
unregisterCommand(commandOrId: Command | string): void {
|
||||
const id = Command.is(commandOrId) ? commandOrId.id : commandOrId;
|
||||
const toUnregister = this.toUnregisterCommands.get(id);
|
||||
if (toUnregister) {
|
||||
toUnregister.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册 handler
|
||||
*/
|
||||
registerHandler(commandId: string, handler: CommandHandler): Disposable {
|
||||
let handlers = this._handlers[commandId];
|
||||
if (!handlers) {
|
||||
this._handlers[commandId] = handlers = [];
|
||||
}
|
||||
handlers.unshift(handler);
|
||||
return {
|
||||
dispose: () => {
|
||||
const idx = handlers.indexOf(handler);
|
||||
if (idx >= 0) {
|
||||
handlers.splice(idx, 1);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 预留 contextMenu 用,该 handler 是否可见
|
||||
*/
|
||||
isVisible(command: string, ...args: any[]): boolean {
|
||||
return typeof this.getVisibleHandler(command, ...args) !== 'undefined';
|
||||
}
|
||||
|
||||
/**
|
||||
* command 是否可用
|
||||
*/
|
||||
isEnabled(command: string, ...args: any[]): boolean {
|
||||
return typeof this.getActiveHandler(command, ...args) !== 'undefined';
|
||||
}
|
||||
|
||||
/**
|
||||
* 预留 contextMenu 用,该 handler 是否可以触发
|
||||
*/
|
||||
isToggled(command: string, ...args: any[]): boolean {
|
||||
return typeof this.getToggledHandler(command, ...args) !== 'undefined';
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 command,会先判断是否可以执行,不会重复执行
|
||||
*/
|
||||
async executeCommand<T>(commandId: string, ...args: any[]): Promise<T | undefined> {
|
||||
const handler = this.getActiveHandler(commandId, ...args);
|
||||
const execInfo: CommandExecuting = { id: commandId, args };
|
||||
const simpleExecInfo = CommandExecuting.findSimple(this._commandExecutings, execInfo);
|
||||
if (simpleExecInfo) {
|
||||
return execInfo.promise;
|
||||
}
|
||||
if (handler) {
|
||||
try {
|
||||
this._commandExecutings.add(execInfo);
|
||||
this.onWillExecuteCommandEmitter.fire({ commandId, args });
|
||||
const promise = handler.execute(...args);
|
||||
execInfo.promise = promise;
|
||||
const result = await promise;
|
||||
this.onDidExecuteCommandEmitter.fire({ commandId, args });
|
||||
return result;
|
||||
} finally {
|
||||
this._commandExecutings.delete(execInfo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getVisibleHandler(commandId: string, ...args: any[]): CommandHandler | undefined {
|
||||
const handlers = this._handlers[commandId];
|
||||
if (handlers) {
|
||||
for (const handler of handlers) {
|
||||
try {
|
||||
if (!handler.isVisible || handler.isVisible(...args)) {
|
||||
return handler;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getActiveHandler(commandId: string, ...args: any[]): CommandHandler | undefined {
|
||||
const handlers = this._handlers[commandId];
|
||||
if (handlers) {
|
||||
for (const handler of handlers) {
|
||||
try {
|
||||
if (!handler.isEnabled || handler.isEnabled(...args)) {
|
||||
return handler;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 command 对应的所有 handler
|
||||
*/
|
||||
getAllHandlers(commandId: string): CommandHandler[] {
|
||||
const handlers = this._handlers[commandId];
|
||||
return handlers ? handlers.slice() : [];
|
||||
}
|
||||
|
||||
getToggledHandler(commandId: string, ...args: any[]): CommandHandler | undefined {
|
||||
const handlers = this._handlers[commandId];
|
||||
if (handlers) {
|
||||
for (const handler of handlers) {
|
||||
try {
|
||||
if (handler.isToggled && handler.isToggled(...args)) {
|
||||
return handler;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 command
|
||||
*/
|
||||
getCommand(id: string): Command | undefined {
|
||||
return this._commands[id];
|
||||
}
|
||||
|
||||
protected doRegisterCommand(command: Command): Disposable {
|
||||
this._commands[command.id] = command;
|
||||
return {
|
||||
dispose: () => {
|
||||
delete this._commands[command.id];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 command
|
||||
*/
|
||||
public updateCommand(id: string, command: Partial<Omit<Command, 'id'>>) {
|
||||
if (this._commands[id]) {
|
||||
this._commands[id] = {
|
||||
...this._commands[id],
|
||||
...command,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.onWillExecuteCommandEmitter.dispose();
|
||||
this.onDidExecuteCommandEmitter.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export {
|
||||
CommandContribution,
|
||||
type CommandHandler,
|
||||
CommandRegistry,
|
||||
Command,
|
||||
CommandRegistryFactory,
|
||||
} from './command';
|
||||
export { CommandService } from './command-service';
|
||||
export { CommandContainerModule } from './command-container-module';
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "@flowgram.ai/ts-config/tsconfig.flow.path.json",
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
commonjsOptions: {
|
||||
transformMixedEsModules: true,
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
mockReset: false,
|
||||
environment: 'jsdom',
|
||||
include: ['**/?(*.){test,spec}.?(c|m)[jt]s?(x)'],
|
||||
exclude: [
|
||||
'**/node_modules/**',
|
||||
'**/dist/**',
|
||||
'**/lib/**', // lib 编译结果忽略掉
|
||||
'**/cypress/**',
|
||||
'**/.{idea,git,cache,output,temp}/**',
|
||||
'**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*',
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import 'reflect-metadata';
|
||||
Reference in New Issue
Block a user