From b3880e9a96c70d750f8f8487913bf6329cdcfb92 Mon Sep 17 00:00:00 2001 From: liujuping Date: Thu, 25 Jan 2024 15:55:32 +0800 Subject: [PATCH] feat(command): add command apis --- packages/designer/src/plugin/plugin-types.ts | 2 + packages/editor-core/src/command.ts | 91 +++ packages/editor-core/src/index.ts | 1 + packages/editor-core/test/command.test.ts | 326 +++++++++++ packages/engine/src/engine-core.ts | 29 + .../src/inner-plugins/default-command.ts | 524 ++++++++++++++++++ .../inner-plugins/default-command.test.ts | 110 ++++ packages/renderer-core/src/utils/common.ts | 2 +- packages/shell/src/api/command.ts | 46 ++ packages/shell/src/api/index.ts | 3 +- packages/shell/src/index.ts | 2 + packages/shell/src/symbols.ts | 3 +- packages/types/src/shell/api/command.ts | 34 ++ packages/types/src/shell/api/index.ts | 1 + .../types/src/shell/model/plugin-context.ts | 3 + packages/types/src/shell/type/command.ts | 71 +++ packages/types/src/shell/type/index.ts | 3 +- packages/types/src/shell/type/plugin-meta.ts | 8 + .../workspace/src/context/base-context.ts | 7 + 19 files changed, 1262 insertions(+), 4 deletions(-) create mode 100644 packages/editor-core/src/command.ts create mode 100644 packages/editor-core/test/command.test.ts create mode 100644 packages/engine/src/inner-plugins/default-command.ts create mode 100644 packages/engine/tests/inner-plugins/default-command.test.ts create mode 100644 packages/shell/src/api/command.ts create mode 100644 packages/types/src/shell/api/command.ts create mode 100644 packages/types/src/shell/type/command.ts diff --git a/packages/designer/src/plugin/plugin-types.ts b/packages/designer/src/plugin/plugin-types.ts index d648067a3..cfc38866f 100644 --- a/packages/designer/src/plugin/plugin-types.ts +++ b/packages/designer/src/plugin/plugin-types.ts @@ -19,6 +19,7 @@ import { IPublicModelWindow, IPublicEnumPluginRegisterLevel, IPublicApiCommonUI, + IPublicApiCommand, } from '@alilc/lowcode-types'; import PluginContext from './plugin-context'; @@ -63,6 +64,7 @@ export interface ILowCodePluginContextPrivate { set registerLevel(level: IPublicEnumPluginRegisterLevel); set isPluginRegisteredInWorkspace(flag: boolean); set commonUI(commonUI: IPublicApiCommonUI); + set command(command: IPublicApiCommand); } export interface ILowCodePluginContextApiAssembler { assembleApis( diff --git a/packages/editor-core/src/command.ts b/packages/editor-core/src/command.ts new file mode 100644 index 000000000..7facc33d9 --- /dev/null +++ b/packages/editor-core/src/command.ts @@ -0,0 +1,91 @@ +import { IPublicApiCommand, IPublicEnumTransitionType, IPublicModelPluginContext, IPublicTypeCommand, IPublicTypeCommandHandlerArgs, IPublicTypeListCommand } from '@alilc/lowcode-types'; +import { checkPropTypes } from '@alilc/lowcode-utils'; +export interface ICommand extends Omit { + registerCommand(command: IPublicTypeCommand, options?: { + commandScope?: string; + }): void; + + batchExecuteCommand(commands: { name: string; args: IPublicTypeCommandHandlerArgs }[], pluginContext?: IPublicModelPluginContext): void; +} + +export interface ICommandOptions { + commandScope?: string; +} + +export class Command implements ICommand { + private commands: Map = new Map(); + private commandErrors: Function[] = []; + + registerCommand(command: IPublicTypeCommand, options?: ICommandOptions): void { + if (!options?.commandScope) { + throw new Error('plugin meta.commandScope is required.'); + } + const name = `${options.commandScope}:${command.name}`; + if (this.commands.has(name)) { + throw new Error(`Command '${command.name}' is already registered.`); + } + this.commands.set(name, { + ...command, + name, + }); + } + + unregisterCommand(name: string): void { + if (!this.commands.has(name)) { + throw new Error(`Command '${name}' is not registered.`); + } + this.commands.delete(name); + } + + executeCommand(name: string, args: IPublicTypeCommandHandlerArgs): void { + const command = this.commands.get(name); + if (!command) { + throw new Error(`Command '${name}' is not registered.`); + } + command.parameters?.forEach(d => { + if (!checkPropTypes(args[d.name], d.name, d.propType, 'command')) { + throw new Error(`Command '${name}' arguments ${d.name} is invalid.`); + } + }); + try { + command.handler(args); + } catch (error) { + if (this.commandErrors && this.commandErrors.length) { + this.commandErrors.forEach(callback => callback(name, error)); + } else { + throw error; + } + } + } + + batchExecuteCommand(commands: { name: string; args: IPublicTypeCommandHandlerArgs }[], pluginContext: IPublicModelPluginContext): void { + if (!commands || !commands.length) { + return; + } + pluginContext.common.utils.executeTransaction(() => { + commands.forEach(command => this.executeCommand(command.name, command.args)); + }, IPublicEnumTransitionType.REPAINT); + } + + listCommands(): IPublicTypeListCommand[] { + return Array.from(this.commands.values()).map(d => { + const result: IPublicTypeListCommand = { + name: d.name, + }; + + if (d.description) { + result.description = d.description; + } + + if (d.parameters) { + result.parameters = d.parameters; + } + + return result; + }); + } + + onCommandError(callback: (name: string, error: Error) => void): void { + this.commandErrors.push(callback); + } +} diff --git a/packages/editor-core/src/index.ts b/packages/editor-core/src/index.ts index 9b0542bda..c4a54bccd 100644 --- a/packages/editor-core/src/index.ts +++ b/packages/editor-core/src/index.ts @@ -6,3 +6,4 @@ export * from './hotkey'; export * from './widgets'; export * from './config'; export * from './event-bus'; +export * from './command'; diff --git a/packages/editor-core/test/command.test.ts b/packages/editor-core/test/command.test.ts new file mode 100644 index 000000000..bb2e15943 --- /dev/null +++ b/packages/editor-core/test/command.test.ts @@ -0,0 +1,326 @@ +import { Command } from '../src/command'; + +describe('Command', () => { + let commandInstance; + let mockHandler; + + beforeEach(() => { + commandInstance = new Command(); + mockHandler = jest.fn(); + }); + + describe('registerCommand', () => { + it('should register a command successfully', () => { + const command = { + name: 'testCommand', + handler: mockHandler, + }; + commandInstance.registerCommand(command, { commandScope: 'testScope' }); + + const registeredCommand = commandInstance.listCommands().find(c => c.name === 'testScope:testCommand'); + expect(registeredCommand).toBeDefined(); + expect(registeredCommand.name).toBe('testScope:testCommand'); + }); + + it('should throw an error if commandScope is not provided', () => { + const command = { + name: 'testCommand', + handler: mockHandler, + }; + + expect(() => { + commandInstance.registerCommand(command); + }).toThrow('plugin meta.commandScope is required.'); + }); + + it('should throw an error if command is already registered', () => { + const command = { + name: 'testCommand', + handler: mockHandler, + }; + commandInstance.registerCommand(command, { commandScope: 'testScope' }); + + expect(() => { + commandInstance.registerCommand(command, { commandScope: 'testScope' }); + }).toThrow(`Command 'testCommand' is already registered.`); + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); +}); + +describe('unregisterCommand', () => { + let commandInstance; + let mockHandler; + + beforeEach(() => { + commandInstance = new Command(); + mockHandler = jest.fn(); + // 先注册一个命令以便之后注销 + const command = { + name: 'testCommand', + handler: mockHandler, + }; + commandInstance.registerCommand(command, { commandScope: 'testScope' }); + }); + + it('should unregister a command successfully', () => { + const commandName = 'testScope:testCommand'; + expect(commandInstance.listCommands().find(c => c.name === commandName)).toBeDefined(); + + commandInstance.unregisterCommand(commandName); + + expect(commandInstance.listCommands().find(c => c.name === commandName)).toBeUndefined(); + }); + + it('should throw an error if the command is not registered', () => { + const nonExistingCommandName = 'testScope:nonExistingCommand'; + expect(() => { + commandInstance.unregisterCommand(nonExistingCommandName); + }).toThrow(`Command '${nonExistingCommandName}' is not registered.`); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); +}); + +describe('executeCommand', () => { + let commandInstance; + let mockHandler; + + beforeEach(() => { + commandInstance = new Command(); + mockHandler = jest.fn(); + // 注册一个带参数校验的命令 + const command = { + name: 'testCommand', + handler: mockHandler, + parameters: [ + { name: 'param1', propType: 'string' }, + { name: 'param2', propType: 'number' } + ], + }; + commandInstance.registerCommand(command, { commandScope: 'testScope' }); + }); + + it('should execute a command successfully', () => { + const commandName = 'testScope:testCommand'; + const args = { param1: 'test', param2: 42 }; + + commandInstance.executeCommand(commandName, args); + + expect(mockHandler).toHaveBeenCalledWith(args); + }); + + it('should throw an error if the command is not registered', () => { + const nonExistingCommandName = 'testScope:nonExistingCommand'; + expect(() => { + commandInstance.executeCommand(nonExistingCommandName, {}); + }).toThrow(`Command '${nonExistingCommandName}' is not registered.`); + }); + + it('should throw an error if arguments are invalid', () => { + const commandName = 'testScope:testCommand'; + const invalidArgs = { param1: 'test', param2: 'not-a-number' }; // param2 should be a number + + expect(() => { + commandInstance.executeCommand(commandName, invalidArgs); + }).toThrow(`Command '${commandName}' arguments param2 is invalid.`); + }); + + it('should handle errors thrown by the command handler', () => { + const commandName = 'testScope:testCommand'; + const args = { param1: 'test', param2: 42 }; + const errorMessage = 'Command handler error'; + mockHandler.mockImplementation(() => { + throw new Error(errorMessage); + }); + + expect(() => { + commandInstance.executeCommand(commandName, args); + }).toThrow(errorMessage); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); +}); + +describe('batchExecuteCommand', () => { + let commandInstance; + let mockHandler; + let mockExecuteTransaction; + let mockPluginContext; + + beforeEach(() => { + commandInstance = new Command(); + mockHandler = jest.fn(); + mockExecuteTransaction = jest.fn(callback => callback()); + mockPluginContext = { + common: { + utils: { + executeTransaction: mockExecuteTransaction + } + } + }; + + // 注册几个命令 + const command1 = { + name: 'testCommand1', + handler: mockHandler, + }; + const command2 = { + name: 'testCommand2', + handler: mockHandler, + }; + commandInstance.registerCommand(command1, { commandScope: 'testScope' }); + commandInstance.registerCommand(command2, { commandScope: 'testScope' }); + }); + + it('should execute a batch of commands', () => { + const commands = [ + { name: 'testScope:testCommand1', args: { param: 'value1' } }, + { name: 'testScope:testCommand2', args: { param: 'value2' } }, + ]; + + commandInstance.batchExecuteCommand(commands, mockPluginContext); + + expect(mockExecuteTransaction).toHaveBeenCalledTimes(1); + expect(mockHandler).toHaveBeenCalledWith({ param: 'value1' }); + expect(mockHandler).toHaveBeenCalledWith({ param: 'value2' }); + }); + + it('should not execute anything if commands array is empty', () => { + commandInstance.batchExecuteCommand([], mockPluginContext); + + expect(mockExecuteTransaction).not.toHaveBeenCalled(); + expect(mockHandler).not.toHaveBeenCalled(); + }); + + it('should handle errors thrown during command execution', () => { + const errorMessage = 'Command handler error'; + mockHandler.mockImplementation(() => { + throw new Error(errorMessage); + }); + + const commands = [ + { name: 'testScope:testCommand1', args: { param: 'value1' } }, + { name: 'testScope:testCommand2', args: { param: 'value2' } }, + ]; + + expect(() => { + commandInstance.batchExecuteCommand(commands, mockPluginContext); + }).toThrow(errorMessage); + + expect(mockExecuteTransaction).toHaveBeenCalledTimes(1); // Still called once + }); + + afterEach(() => { + jest.clearAllMocks(); + }); +}); + +describe('listCommands', () => { + let commandInstance; + let mockHandler; + + beforeEach(() => { + commandInstance = new Command(); + mockHandler = jest.fn(); + }); + + it('should list all registered commands', () => { + // 注册几个命令 + const command1 = { + name: 'testCommand1', + handler: mockHandler, + description: 'Test Command 1', + parameters: [{ name: 'param1', propType: 'string' }] + }; + const command2 = { + name: 'testCommand2', + handler: mockHandler, + description: 'Test Command 2', + parameters: [{ name: 'param2', propType: 'number' }] + }; + commandInstance.registerCommand(command1, { commandScope: 'testScope' }); + commandInstance.registerCommand(command2, { commandScope: 'testScope' }); + + const listedCommands = commandInstance.listCommands(); + + expect(listedCommands.length).toBe(2); + expect(listedCommands).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: 'testScope:testCommand1', + description: 'Test Command 1', + parameters: [{ name: 'param1', propType: 'string' }] + }), + expect.objectContaining({ + name: 'testScope:testCommand2', + description: 'Test Command 2', + parameters: [{ name: 'param2', propType: 'number' }] + }) + ])); + }); + + it('should return an empty array if no commands are registered', () => { + const listedCommands = commandInstance.listCommands(); + expect(listedCommands).toEqual([]); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); +}); + +describe('onCommandError', () => { + let commandInstance; + let mockHandler; + let mockErrorHandler1; + let mockErrorHandler2; + + beforeEach(() => { + commandInstance = new Command(); + mockHandler = jest.fn(); + mockErrorHandler1 = jest.fn(); + mockErrorHandler2 = jest.fn(); + + // 注册一个命令,该命令会抛出错误 + const command = { + name: 'testCommand', + handler: () => { + throw new Error('Command execution failed'); + }, + }; + commandInstance.registerCommand(command, { commandScope: 'testScope' }); + }); + + it('should call all registered error handlers when a command throws an error', () => { + const commandName = 'testScope:testCommand'; + commandInstance.onCommandError(mockErrorHandler1); + commandInstance.onCommandError(mockErrorHandler2); + + expect(() => { + commandInstance.executeCommand(commandName, {}); + }).not.toThrow(); + + // 确保所有错误处理函数都被调用,并且传递了正确的参数 + expect(mockErrorHandler1).toHaveBeenCalledWith(commandName, expect.any(Error)); + expect(mockErrorHandler2).toHaveBeenCalledWith(commandName, expect.any(Error)); + }); + + it('should throw the error if no error handlers are registered', () => { + const commandName = 'testScope:testCommand'; + + expect(() => { + commandInstance.executeCommand(commandName, {}); + }).toThrow('Command execution failed'); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); +}); diff --git a/packages/engine/src/engine-core.ts b/packages/engine/src/engine-core.ts index 3ccfdf51d..778438a3d 100644 --- a/packages/engine/src/engine-core.ts +++ b/packages/engine/src/engine-core.ts @@ -10,6 +10,7 @@ import { Setters as InnerSetters, Hotkey as InnerHotkey, IEditor, + Command as InnerCommand, } from '@alilc/lowcode-editor-core'; import { IPublicTypeEngineOptions, @@ -19,6 +20,7 @@ import { IPublicApiPlugins, IPublicApiWorkspace, IPublicEnumPluginRegisterLevel, + IPublicModelPluginContext, } from '@alilc/lowcode-types'; import { Designer, @@ -52,6 +54,7 @@ import { Workspace, Config, CommonUI, + Command, } from '@alilc/lowcode-shell'; import { isPlainObject } from '@alilc/lowcode-utils'; import './modules/live-editing'; @@ -63,6 +66,7 @@ import { defaultPanelRegistry } from './inner-plugins/default-panel-registry'; import { shellModelFactory } from './modules/shell-model-factory'; import { builtinHotkey } from './inner-plugins/builtin-hotkey'; import { defaultContextMenu } from './inner-plugins/default-context-menu'; +import { defaultCommand } from './inner-plugins/default-command'; import { OutlinePlugin } from '@alilc/lowcode-plugin-outline-pane'; export * from './modules/skeleton-types'; @@ -80,6 +84,7 @@ async function registryInnerPlugin(designer: IDesigner, editor: IEditor, plugins await plugins.register(builtinHotkey); await plugins.register(registerDefaults, {}, { autoInit: true }); await plugins.register(defaultContextMenu); + await plugins.register(defaultCommand, {}); return () => { plugins.delete(OutlinePlugin.pluginName); @@ -89,6 +94,7 @@ async function registryInnerPlugin(designer: IDesigner, editor: IEditor, plugins plugins.delete(builtinHotkey.pluginName); plugins.delete(registerDefaults.pluginName); plugins.delete(defaultContextMenu.pluginName); + plugins.delete(defaultCommand.pluginName); }; } @@ -99,6 +105,8 @@ globalContext.register(editor, Editor); globalContext.register(editor, 'editor'); globalContext.register(innerWorkspace, 'workspace'); +const engineContext: Partial = {}; + const innerSkeleton = new InnerSkeleton(editor); editor.set('skeleton' as any, innerSkeleton); @@ -113,6 +121,8 @@ const project = new Project(innerProject); const skeleton = new Skeleton(innerSkeleton, 'any', false); const innerSetters = new InnerSetters(); const setters = new Setters(innerSetters); +const innerCommand = new InnerCommand(); +const command = new Command(innerCommand, engineContext as IPublicModelPluginContext); const material = new Material(editor); const commonUI = new CommonUI(editor); @@ -136,6 +146,7 @@ const pluginContextApiAssembler: ILowCodePluginContextApiAssembler = { context.setters = setters; context.material = material; const eventPrefix = meta?.eventPrefix || 'common'; + const commandScope = meta?.commandScope; context.event = new Event(commonEvent, { prefix: eventPrefix }); context.config = config; context.common = common; @@ -144,6 +155,9 @@ const pluginContextApiAssembler: ILowCodePluginContextApiAssembler = { context.logger = new Logger({ level: 'warn', bizName: `plugin:${pluginName}` }); context.workspace = workspace; context.commonUI = commonUI; + context.command = new Command(innerCommand, context as IPublicModelPluginContext, { + commandScope, + }); context.registerLevel = IPublicEnumPluginRegisterLevel.Default; context.isPluginRegisteredInWorkspace = false; editor.set('pluginContext', context); @@ -155,6 +169,20 @@ plugins = new Plugins(innerPlugins).toProxy(); editor.set('innerPlugins' as any, innerPlugins); editor.set('plugins' as any, plugins); +engineContext.skeleton = skeleton; +engineContext.plugins = plugins; +engineContext.project = project; +engineContext.setters = setters; +engineContext.material = material; +engineContext.event = event; +engineContext.logger = logger; +engineContext.hotkey = hotkey; +engineContext.common = common; +engineContext.workspace = workspace; +engineContext.canvas = canvas; +engineContext.commonUI = commonUI; +engineContext.command = command; + export { skeleton, plugins, @@ -169,6 +197,7 @@ export { workspace, canvas, commonUI, + command, }; // declare this is open-source version export const isOpenSource = true; diff --git a/packages/engine/src/inner-plugins/default-command.ts b/packages/engine/src/inner-plugins/default-command.ts new file mode 100644 index 000000000..b2b899acb --- /dev/null +++ b/packages/engine/src/inner-plugins/default-command.ts @@ -0,0 +1,524 @@ +import { + IPublicModelPluginContext, + IPublicTypeNodeSchema, + IPublicTypePropType, +} from '@alilc/lowcode-types'; +import { isNodeSchema } from '@alilc/lowcode-utils'; + +const sampleNodeSchema: IPublicTypePropType = { + type: 'shape', + value: [ + { + name: 'id', + propType: { + type: 'string', + isRequired: true, + }, + }, + { + name: 'componentName', + propType: { + type: 'string', + isRequired: true, + }, + }, + { + name: 'props', + propType: 'object', + }, + { + name: 'condition', + propType: 'any', + }, + { + name: 'loop', + propType: 'any', + }, + { + name: 'loopArgs', + propType: 'any', + }, + { + name: 'children', + propType: 'any', + }, + ], +}; + +export const nodeSchemaPropType: IPublicTypePropType = { + type: 'shape', + value: [ + sampleNodeSchema.value[0], + sampleNodeSchema.value[1], + { + name: 'props', + propType: { + type: 'objectOf', + value: { + type: 'oneOfType', + // 不会强制校验,更多作为提示 + value: [ + 'any', + { + type: 'shape', + value: [ + { + name: 'type', + propType: { + type: 'oneOf', + value: ['JSExpression'], + }, + }, + { + name: 'value', + propType: 'string', + }, + ], + }, + { + type: 'shape', + value: [ + { + name: 'type', + propType: { + type: 'oneOf', + value: ['JSFunction'], + }, + }, + { + name: 'value', + propType: 'string', + }, + ], + }, + { + type: 'shape', + value: [ + { + name: 'type', + propType: { + type: 'oneOf', + value: ['JSSlot'], + }, + }, + { + name: 'value', + propType: { + type: 'oneOfType', + value: [ + sampleNodeSchema, + { + type: 'arrayOf', + value: sampleNodeSchema, + }, + ], + }, + }, + ], + }, + ], + }, + }, + }, + { + name: 'condition', + propType: { + type: 'oneOfType', + value: [ + 'bool', + { + type: 'shape', + value: [ + { + name: 'type', + propType: { + type: 'oneOf', + value: ['JSExpression'], + }, + }, + { + name: 'value', + propType: 'string', + }, + ], + }, + ], + }, + }, + { + name: 'loop', + propType: { + type: 'oneOfType', + value: [ + 'array', + { + type: 'shape', + value: [ + { + name: 'type', + propType: { + type: 'oneOf', + value: ['JSExpression'], + }, + }, + { + name: 'value', + propType: 'string', + }, + ], + }, + ], + }, + }, + { + name: 'loopArgs', + propType: { + type: 'oneOfType', + value: [ + { + type: 'arrayOf', + value: { + type: 'oneOfType', + value: [ + 'any', + { + type: 'shape', + value: [ + { + name: 'type', + propType: { + type: 'oneOf', + value: ['JSExpression'], + }, + }, + { + name: 'value', + propType: 'string', + }, + ], + }, + ], + }, + }, + { + type: 'shape', + value: [ + { + name: 'type', + propType: { + type: 'oneOf', + value: ['JSExpression'], + }, + }, + { + name: 'value', + propType: 'string', + }, + ], + }, + ], + }, + }, + { + name: 'children', + propType: { + type: 'arrayOf', + value: sampleNodeSchema, + }, + }, + ], +}; + +export const historyCommand = (ctx: IPublicModelPluginContext) => { + const { command, project } = ctx; + return { + init() { + command.registerCommand({ + name: 'undo', + description: 'Undo the last operation.', + handler: () => { + const state = project.currentDocument?.history.getState() || 0; + const enable = !!(state & 1); + if (!enable) { + throw new Error('Can not undo.'); + } + project.currentDocument?.history.back(); + }, + }); + + command.registerCommand({ + name: 'redo', + description: 'Redo the last operation.', + handler: () => { + const state = project.currentDocument?.history.getState() || 0; + const enable = !!(state & 2); + if (!enable) { + throw new Error('Can not redo.'); + } + project.currentDocument?.history.forward(); + }, + }); + }, + }; +}; + +historyCommand.pluginName = '___history_command___'; +historyCommand.meta = { + commandScope: 'history', +}; + +export const nodeCommand = (ctx: IPublicModelPluginContext) => { + const { command, project } = ctx; + return { + init() { + command.registerCommand({ + name: 'add', + description: 'Add a node to the canvas.', + handler: (param: { + parentNodeId: string; + nodeSchema: IPublicTypeNodeSchema; + }) => { + const { + parentNodeId, + nodeSchema, + } = param; + const { project } = ctx; + const parentNode = project.currentDocument?.getNodeById(parentNodeId); + if (!parentNode) { + throw new Error(`Can not find node '${parentNodeId}'.`); + } + + if (!parentNode.isContainerNode) { + throw new Error(`Node '${parentNodeId}' is not a container node.`); + } + + if (!isNodeSchema(nodeSchema)) { + throw new Error('Invalid node.'); + } + + project.currentDocument?.insertNode(parentNode, nodeSchema); + }, + parameters: [ + { + name: 'parentNodeId', + propType: 'string', + description: 'The id of the parent node.', + }, + { + name: 'nodeSchema', + propType: nodeSchemaPropType, + description: 'The node to be added.', + }, + ], + }); + + command.registerCommand({ + name: 'move', + description: 'Move a node to another node.', + handler(param: { + nodeId: string; + targetNodeId: string; + index: number; + }) { + const { + nodeId, + targetNodeId, + index = 0, + } = param; + + const node = project.currentDocument?.getNodeById(nodeId); + const targetNode = project.currentDocument?.getNodeById(targetNodeId); + if (!node) { + throw new Error(`Can not find node '${nodeId}'.`); + } + + if (!targetNode) { + throw new Error(`Can not find node '${targetNodeId}'.`); + } + + if (!targetNode.isContainerNode) { + throw new Error(`Node '${targetNodeId}' is not a container node.`); + } + + if (index < 0 || index > (targetNode.children?.size || 0)) { + throw new Error(`Invalid index '${index}'.`); + } + + project.currentDocument?.removeNode(node); + project.currentDocument?.insertNode(targetNode, node, index); + }, + parameters: [ + { + name: 'nodeId', + propType: 'string', + description: 'The id of the node to be moved.', + }, + { + name: 'targetNodeId', + propType: 'string', + description: 'The id of the target node.', + }, + { + name: 'index', + propType: 'number', + description: 'The index of the node to be moved.', + }, + ], + }); + + command.registerCommand({ + name: 'remove', + description: 'Remove a node from the canvas.', + handler(param: { + nodeId: string; + }) { + const { + nodeId, + } = param; + + const node = project.currentDocument?.getNodeById(nodeId); + if (!node) { + throw new Error(`Can not find node '${nodeId}'.`); + } + + project.currentDocument?.removeNode(node); + }, + parameters: [ + { + name: 'nodeId', + propType: 'string', + description: 'The id of the node to be removed.', + }, + ], + }); + + command.registerCommand({ + name: 'replace', + description: 'Replace a node with another node.', + handler(param: { + nodeId: string; + nodeSchema: IPublicTypeNodeSchema; + }) { + const { + nodeId, + nodeSchema, + } = param; + + const node = project.currentDocument?.getNodeById(nodeId); + if (!node) { + throw new Error(`Can not find node '${nodeId}'.`); + } + + if (!isNodeSchema(nodeSchema)) { + throw new Error('Invalid node.'); + } + + node.importSchema(nodeSchema); + }, + parameters: [ + { + name: 'nodeId', + propType: 'string', + description: 'The id of the node to be replaced.', + }, + { + name: 'nodeSchema', + propType: nodeSchemaPropType, + description: 'The node to replace.', + }, + ], + }); + + command.registerCommand({ + name: 'updateProps', + description: 'Update the properties of a node.', + handler(param: { + nodeId: string; + props: Record; + }) { + const { + nodeId, + props, + } = param; + + const node = project.currentDocument?.getNodeById(nodeId); + if (!node) { + throw new Error(`Can not find node '${nodeId}'.`); + } + + Object.keys(props).forEach(key => { + node.setPropValue(key, props[key]); + }); + }, + parameters: [ + { + name: 'nodeId', + propType: 'string', + description: 'The id of the node to be updated.', + }, + { + name: 'props', + propType: 'object', + description: 'The properties to be updated.', + }, + ], + }); + + command.registerCommand({ + name: 'removeProps', + description: 'Remove the properties of a node.', + handler(param: { + nodeId: string; + propNames: string[]; + }) { + const { + nodeId, + propNames, + } = param; + + const node = project.currentDocument?.getNodeById(nodeId); + if (!node) { + throw new Error(`Can not find node '${nodeId}'.`); + } + + propNames.forEach(key => { + node.props?.getProp(key)?.remove(); + }); + }, + parameters: [ + { + name: 'nodeId', + propType: 'string', + description: 'The id of the node to be updated.', + }, + { + name: 'propNames', + propType: 'array', + description: 'The properties to be removed.', + }, + ], + }); + }, + }; +}; + +nodeCommand.pluginName = '___node_command___'; +nodeCommand.meta = { + commandScope: 'node', +}; + +export const defaultCommand = (ctx: IPublicModelPluginContext) => { + const { plugins } = ctx; + plugins.register(nodeCommand); + plugins.register(historyCommand); + + return { + init() { + }, + }; +}; + +defaultCommand.pluginName = '___default_command___'; +defaultCommand.meta = { + commandScope: 'common', +}; diff --git a/packages/engine/tests/inner-plugins/default-command.test.ts b/packages/engine/tests/inner-plugins/default-command.test.ts new file mode 100644 index 000000000..0e28c642b --- /dev/null +++ b/packages/engine/tests/inner-plugins/default-command.test.ts @@ -0,0 +1,110 @@ +import { checkPropTypes } from '@alilc/lowcode-utils/src/check-prop-types'; +import { nodeSchemaPropType } from '../../src/inner-plugins/default-command'; + +describe('nodeSchemaPropType', () => { + const componentName = 'NodeComponent'; + const getPropType = (name: string) => nodeSchemaPropType.value.find(d => d.name === name)?.propType; + + it('should validate the id as a string', () => { + const validId = 'node1'; + const invalidId = 123; // Not a string + expect(checkPropTypes(validId, 'id', getPropType('id'), componentName)).toBe(true); + expect(checkPropTypes(invalidId, 'id', getPropType('id'), componentName)).toBe(false); + // isRequired + expect(checkPropTypes(undefined, 'id', getPropType('id'), componentName)).toBe(false); + }); + + it('should validate the componentName as a string', () => { + const validComponentName = 'Button'; + const invalidComponentName = false; // Not a string + expect(checkPropTypes(validComponentName, 'componentName', getPropType('componentName'), componentName)).toBe(true); + expect(checkPropTypes(invalidComponentName, 'componentName', getPropType('componentName'), componentName)).toBe(false); + // isRequired + expect(checkPropTypes(undefined, 'componentName', getPropType('componentName'), componentName)).toBe(false); + }); + + it('should validate the props as an object', () => { + const validProps = { key: 'value' }; + const invalidProps = 'Not an object'; // Not an object + expect(checkPropTypes(validProps, 'props', getPropType('props'), componentName)).toBe(true); + expect(checkPropTypes(invalidProps, 'props', getPropType('props'), componentName)).toBe(false); + }); + + it('should validate the props as a JSExpression', () => { + const validProps = { type: 'JSExpression', value: 'props' }; + expect(checkPropTypes(validProps, 'props', getPropType('props'), componentName)).toBe(true); + }); + + it('should validate the props as a JSFunction', () => { + const validProps = { type: 'JSFunction', value: 'props' }; + expect(checkPropTypes(validProps, 'props', getPropType('props'), componentName)).toBe(true); + }); + + it('should validate the props as a JSSlot', () => { + const validProps = { type: 'JSSlot', value: 'props' }; + expect(checkPropTypes(validProps, 'props', getPropType('props'), componentName)).toBe(true); + }); + + it('should validate the condition as a bool', () => { + const validCondition = true; + const invalidCondition = 'Not a bool'; // Not a boolean + expect(checkPropTypes(validCondition, 'condition', getPropType('condition'), componentName)).toBe(true); + expect(checkPropTypes(invalidCondition, 'condition', getPropType('condition'), componentName)).toBe(false); + }); + + it('should validate the condition as a JSExpression', () => { + const validCondition = { type: 'JSExpression', value: '1 + 1 === 2' }; + const invalidCondition = { type: 'JSExpression', value: 123 }; // Not a string + expect(checkPropTypes(validCondition, 'condition', getPropType('condition'), componentName)).toBe(true); + expect(checkPropTypes(invalidCondition, 'condition', getPropType('condition'), componentName)).toBe(false); + }); + + it('should validate the loop as an array', () => { + const validLoop = ['item1', 'item2']; + const invalidLoop = 'Not an array'; // Not an array + expect(checkPropTypes(validLoop, 'loop', getPropType('loop'), componentName)).toBe(true); + expect(checkPropTypes(invalidLoop, 'loop', getPropType('loop'), componentName)).toBe(false); + }); + + it('should validate the loop as a JSExpression', () => { + const validLoop = { type: 'JSExpression', value: 'items' }; + const invalidLoop = { type: 'JSExpression', value: 123 }; // Not a string + expect(checkPropTypes(validLoop, 'loop', getPropType('loop'), componentName)).toBe(true); + expect(checkPropTypes(invalidLoop, 'loop', getPropType('loop'), componentName)).toBe(false); + }) + + it('should validate the loopArgs as an array', () => { + const validLoopArgs = ['item']; + const invalidLoopArgs = 'Not an array'; // Not an array + expect(checkPropTypes(validLoopArgs, 'loopArgs', getPropType('loopArgs'), componentName)).toBe(true); + expect(checkPropTypes(invalidLoopArgs, 'loopArgs', getPropType('loopArgs'), componentName)).toBe(false); + }); + + it('should validate the loopArgs as a JSExpression', () => { + const validLoopArgs = { type: 'JSExpression', value: 'item' }; + const invalidLoopArgs = { type: 'JSExpression', value: 123 }; // Not a string + const validLoopArgs2 = [{ type: 'JSExpression', value: 'item' }, { type: 'JSExpression', value: 'index' }]; + expect(checkPropTypes(validLoopArgs, 'loopArgs', getPropType('loopArgs'), componentName)).toBe(true); + expect(checkPropTypes(invalidLoopArgs, 'loopArgs', getPropType('loopArgs'), componentName)).toBe(false); + expect(checkPropTypes(validLoopArgs2, 'loopArgs', getPropType('loopArgs'), componentName)).toBe(true); + }); + + it('should validate the children as an array', () => { + const validChildren = [{ + id: 'child1', + componentName: 'Button', + }, { + id: 'child2', + componentName: 'Button', + }]; + const invalidChildren = 'Not an array'; // Not an array + const invalidChildren2 = [{}]; // Not an valid array + expect(checkPropTypes(invalidChildren, 'children', getPropType('children'), componentName)).toBe(false); + expect(checkPropTypes(validChildren, 'children', getPropType('children'), componentName)).toBe(true); + expect(checkPropTypes(invalidChildren2, 'children', getPropType('children'), componentName)).toBe(false); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); +}); diff --git a/packages/renderer-core/src/utils/common.ts b/packages/renderer-core/src/utils/common.ts index 80150f379..0462d358a 100644 --- a/packages/renderer-core/src/utils/common.ts +++ b/packages/renderer-core/src/utils/common.ts @@ -1,7 +1,7 @@ /* eslint-disable no-console */ /* eslint-disable no-new-func */ import logger from './logger'; -import { IPublicTypeRootSchema, IPublicTypeNodeSchema, IPublicTypeJSSlot, IPublicTypePropType, IPublicTypeRequiredType, IPublicTypeBasicType } from '@alilc/lowcode-types'; +import { IPublicTypeRootSchema, IPublicTypeNodeSchema, IPublicTypeJSSlot } from '@alilc/lowcode-types'; import { isI18nData, isJSExpression } from '@alilc/lowcode-utils'; import { isEmpty } from 'lodash'; import IntlMessageFormat from 'intl-messageformat'; diff --git a/packages/shell/src/api/command.ts b/packages/shell/src/api/command.ts new file mode 100644 index 000000000..ebab4a9ff --- /dev/null +++ b/packages/shell/src/api/command.ts @@ -0,0 +1,46 @@ +import { IPublicApiCommand, IPublicModelPluginContext, IPublicTypeCommand, IPublicTypeCommandHandlerArgs, IPublicTypeListCommand } from '@alilc/lowcode-types'; +import { commandSymbol, pluginContextSymbol } from '../symbols'; +import { ICommand, ICommandOptions } from '@alilc/lowcode-editor-core'; + +const optionsSymbol = Symbol('options'); +const commandScopeSet = new Set(); + +export class Command implements IPublicApiCommand { + [commandSymbol]: ICommand; + [optionsSymbol]?: ICommandOptions; + [pluginContextSymbol]?: IPublicModelPluginContext; + + constructor(innerCommand: ICommand, pluginContext?: IPublicModelPluginContext, options?: ICommandOptions) { + this[commandSymbol] = innerCommand; + this[optionsSymbol] = options; + this[pluginContextSymbol] = pluginContext; + const commandScope = options?.commandScope; + if (commandScope && commandScopeSet.has(commandScope)) { + throw new Error(`Command scope "${commandScope}" has been registered.`); + } + } + + registerCommand(command: IPublicTypeCommand): void { + this[commandSymbol].registerCommand(command, this[optionsSymbol]); + } + + batchExecuteCommand(commands: { name: string; args: IPublicTypeCommandHandlerArgs }[]): void { + this[commandSymbol].batchExecuteCommand(commands, this[pluginContextSymbol]); + } + + executeCommand(name: string, args: IPublicTypeCommandHandlerArgs): void { + this[commandSymbol].executeCommand(name, args); + } + + listCommands(): IPublicTypeListCommand[] { + return this[commandSymbol].listCommands(); + } + + unregisterCommand(name: string): void { + this[commandSymbol].unregisterCommand(name); + } + + onCommandError(callback: (name: string, error: Error) => void): void { + this[commandSymbol].onCommandError(callback); + } +} diff --git a/packages/shell/src/api/index.ts b/packages/shell/src/api/index.ts index 3726020de..79340f677 100644 --- a/packages/shell/src/api/index.ts +++ b/packages/shell/src/api/index.ts @@ -11,4 +11,5 @@ export * from './skeleton'; export * from './canvas'; export * from './workspace'; export * from './config'; -export * from './commonUI'; \ No newline at end of file +export * from './commonUI'; +export * from './command'; \ No newline at end of file diff --git a/packages/shell/src/index.ts b/packages/shell/src/index.ts index ce09ccaaa..fb1e7228f 100644 --- a/packages/shell/src/index.ts +++ b/packages/shell/src/index.ts @@ -29,6 +29,7 @@ import { SimulatorHost, Config, CommonUI, + Command, } from './api'; export * from './symbols'; @@ -70,4 +71,5 @@ export { SettingField, SkeletonItem, CommonUI, + Command, }; diff --git a/packages/shell/src/symbols.ts b/packages/shell/src/symbols.ts index 8e2962a24..e0f846ad3 100644 --- a/packages/shell/src/symbols.ts +++ b/packages/shell/src/symbols.ts @@ -39,4 +39,5 @@ export const configSymbol = Symbol('configSymbol'); export const conditionGroupSymbol = Symbol('conditionGroup'); export const editorViewSymbol = Symbol('editorView'); export const pluginContextSymbol = Symbol('pluginContext'); -export const skeletonItemSymbol = Symbol('skeletonItem'); \ No newline at end of file +export const skeletonItemSymbol = Symbol('skeletonItem'); +export const commandSymbol = Symbol('command'); \ No newline at end of file diff --git a/packages/types/src/shell/api/command.ts b/packages/types/src/shell/api/command.ts new file mode 100644 index 000000000..5cbfaa2e1 --- /dev/null +++ b/packages/types/src/shell/api/command.ts @@ -0,0 +1,34 @@ +import { IPublicTypeCommand, IPublicTypeCommandHandlerArgs, IPublicTypeListCommand } from '../type'; + +export interface IPublicApiCommand { + + /** + * 注册一个新命令及其处理函数 + */ + registerCommand(command: IPublicTypeCommand): void; + + /** + * 注销一个已存在的命令 + */ + unregisterCommand(name: string): void; + + /** + * 通过名称和给定参数执行一个命令,会校验参数是否符合命令定义 + */ + executeCommand(name: string, args: IPublicTypeCommandHandlerArgs): void; + + /** + * 批量执行命令,执行完所有命令后再进行一次重绘,历史记录中只会记录一次 + */ + batchExecuteCommand(commands: { name: string; args: IPublicTypeCommandHandlerArgs }[]): void; + + /** + * 列出所有已注册的命令 + */ + listCommands(): IPublicTypeListCommand[]; + + /** + * 注册错误处理回调函数 + */ + onCommandError(callback: (name: string, error: Error) => void): void; +} \ No newline at end of file diff --git a/packages/types/src/shell/api/index.ts b/packages/types/src/shell/api/index.ts index 79f1b0dc7..8f14d8dad 100644 --- a/packages/types/src/shell/api/index.ts +++ b/packages/types/src/shell/api/index.ts @@ -11,3 +11,4 @@ export * from './logger'; export * from './canvas'; export * from './workspace'; export * from './commonUI'; +export * from './command'; \ No newline at end of file diff --git a/packages/types/src/shell/model/plugin-context.ts b/packages/types/src/shell/model/plugin-context.ts index 45568424d..d4d715e96 100644 --- a/packages/types/src/shell/model/plugin-context.ts +++ b/packages/types/src/shell/model/plugin-context.ts @@ -12,6 +12,7 @@ import { IPublicApiPlugins, IPublicApiWorkspace, IPublicApiCommonUI, + IPublicApiCommand, } from '../api'; import { IPublicEnumPluginRegisterLevel } from '../enum'; import { IPublicModelEngineConfig, IPublicModelWindow } from './'; @@ -109,6 +110,8 @@ export interface IPublicModelPluginContext { */ get commonUI(): IPublicApiCommonUI; + get command(): IPublicApiCommand; + /** * 插件注册层级 * @since v1.1.7 diff --git a/packages/types/src/shell/type/command.ts b/packages/types/src/shell/type/command.ts new file mode 100644 index 000000000..dd0420def --- /dev/null +++ b/packages/types/src/shell/type/command.ts @@ -0,0 +1,71 @@ +// 定义命令处理函数的参数类型 +export interface IPublicTypeCommandHandlerArgs { + [key: string]: any; +} + +// 定义复杂参数类型的接口 +export interface IPublicTypeCommandPropType { + + /** + * 参数基础类型 + */ + type: string; + + /** + * 参数是否必需(可选) + */ + isRequired?: boolean; +} + +// 定义命令参数的接口 +export interface IPublicTypeCommandParameter { + + /** + * 参数名称 + */ + name: string; + + /** + * 参数类型或详细类型描述 + */ + propType: string | IPublicTypeCommandPropType; + + /** + * 参数描述 + */ + description: string; + + /** + * 参数默认值(可选) + */ + defaultValue?: any; +} + +// 定义单个命令的接口 +export interface IPublicTypeCommand { + + /** + * 命令名称 + * 命名规则:commandName + * 使用规则:commandScope:commandName (commandScope 在插件 meta 中定义,用于区分不同插件的命令) + */ + name: string; + + /** + * 命令参数 + */ + parameters?: IPublicTypeCommandParameter[]; + + /** + * 命令描述 + */ + description?: string; + + /** + * 命令处理函数 + */ + handler: (args: any) => void; +} + +export interface IPublicTypeListCommand extends Pick { +} \ No newline at end of file diff --git a/packages/types/src/shell/type/index.ts b/packages/types/src/shell/type/index.ts index b1c7779d0..76dd38925 100644 --- a/packages/types/src/shell/type/index.ts +++ b/packages/types/src/shell/type/index.ts @@ -92,4 +92,5 @@ export * from './hotkey-callbacks'; export * from './scrollable'; export * from './simulator-renderer'; export * from './config-transducer'; -export * from './context-menu'; \ No newline at end of file +export * from './context-menu'; +export * from './command'; \ No newline at end of file diff --git a/packages/types/src/shell/type/plugin-meta.ts b/packages/types/src/shell/type/plugin-meta.ts index 421e59ad0..bf7f6212e 100644 --- a/packages/types/src/shell/type/plugin-meta.ts +++ b/packages/types/src/shell/type/plugin-meta.ts @@ -1,14 +1,17 @@ import { IPublicTypePluginDeclaration } from './'; export interface IPublicTypePluginMeta { + /** * define dependencies which the plugin depends on */ dependencies?: string[]; + /** * specify which engine version is compatible with the plugin */ engines?: { + /** e.g. '^1.0.0' */ lowcodeEngine?: string; }; @@ -26,4 +29,9 @@ export interface IPublicTypePluginMeta { * event.emit('someEventName') is actually sending event with name 'myEvent:someEventName' */ eventPrefix?: string; + + /** + * 如果要使用 command 注册命令,需要在插件 meta 中定义 commandScope + */ + commandScope?: string; } diff --git a/packages/workspace/src/context/base-context.ts b/packages/workspace/src/context/base-context.ts index 2f6154788..db33597ae 100644 --- a/packages/workspace/src/context/base-context.ts +++ b/packages/workspace/src/context/base-context.ts @@ -5,6 +5,7 @@ import { commonEvent, IEngineConfig, IHotKey, + Command as InnerCommand, } from '@alilc/lowcode-editor-core'; import { Designer, @@ -33,6 +34,7 @@ import { Window, Canvas, CommonUI, + Command, } from '@alilc/lowcode-shell'; import { IPluginPreferenceMananger, @@ -129,6 +131,7 @@ export class BasicContext implements IBasicContext { const skeleton = new Skeleton(innerSkeleton, 'any', true); const canvas = new Canvas(editor, true); const commonUI = new CommonUI(editor); + const innerCommand = new InnerCommand(); editor.set('setters', setters); editor.set('project', project); editor.set('material', material); @@ -162,6 +165,7 @@ export class BasicContext implements IBasicContext { context.setters = setters; context.material = material; const eventPrefix = meta?.eventPrefix || 'common'; + const commandScope = meta?.commandScope; context.event = new Event(commonEvent, { prefix: eventPrefix }); context.config = config; context.common = common; @@ -172,6 +176,9 @@ export class BasicContext implements IBasicContext { if (editorWindow) { context.editorWindow = new Window(editorWindow); } + context.command = new Command(innerCommand, context as IPublicModelPluginContext, { + commandScope, + }); context.registerLevel = registerLevel; context.isPluginRegisteredInWorkspace = registerLevel === IPublicEnumPluginRegisterLevel.Workspace; editor.set('pluginContext', context);