From e767c353706e39ab4ccf3b29388e4856e767ebd6 Mon Sep 17 00:00:00 2001 From: Martin Fleck Date: Fri, 31 Jan 2025 14:19:26 +0100 Subject: [PATCH] Allow the usage of React components as UI extensions --- package-lock.json | 2 + packages/editor/package.json | 4 +- .../jump/{jump-out-ui.ts => jump-out-ui.tsx} | 29 ++--- .../tool-bar/{tool-bar.ts => tool-bar.tsx} | 55 +++++++++- .../src/ui-tools/viewport/viewport-bar.ts | 103 ------------------ .../src/ui-tools/viewport/viewport-bar.tsx | 102 +++++++++++++++++ .../editor/src/utils/react-ui-extension.tsx | 39 +++++++ packages/editor/src/utils/ui-components.tsx | 35 ++++++ .../src/inscription/inscription-ui.tsx | 70 ++++++------ .../src/inscription/react-ui-extension.tsx | 37 +++++++ 10 files changed, 321 insertions(+), 155 deletions(-) rename packages/editor/src/jump/{jump-out-ui.ts => jump-out-ui.tsx} (57%) rename packages/editor/src/ui-tools/tool-bar/{tool-bar.ts => tool-bar.tsx} (80%) delete mode 100644 packages/editor/src/ui-tools/viewport/viewport-bar.ts create mode 100644 packages/editor/src/ui-tools/viewport/viewport-bar.tsx create mode 100644 packages/editor/src/utils/react-ui-extension.tsx create mode 100644 packages/editor/src/utils/ui-components.tsx create mode 100644 packages/inscription/src/inscription/react-ui-extension.tsx diff --git a/package-lock.json b/package-lock.json index 8e3e2474d..6791c2a8c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17309,6 +17309,8 @@ "@axonivy/process-editor-protocol": "~13.1.0-next", "@eclipse-glsp/client": "2.3.0", "marked": "^15.0.6", + "react": "^19.0.0", + "react-dom": "^19.0.0", "toastify-js": "1.12.0" }, "devDependencies": { diff --git a/packages/editor/package.json b/packages/editor/package.json index b7d858b00..d8c9d4ecf 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -21,7 +21,9 @@ "@axonivy/process-editor-protocol": "~13.1.0-next", "@eclipse-glsp/client": "2.3.0", "marked": "^15.0.6", - "toastify-js": "1.12.0" + "toastify-js": "1.12.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" }, "devDependencies": { "@types/lodash": "4.17.14", diff --git a/packages/editor/src/jump/jump-out-ui.ts b/packages/editor/src/jump/jump-out-ui.tsx similarity index 57% rename from packages/editor/src/jump/jump-out-ui.ts rename to packages/editor/src/jump/jump-out-ui.tsx index 0719caee1..2e7cb9d88 100644 --- a/packages/editor/src/jump/jump-out-ui.ts +++ b/packages/editor/src/jump/jump-out-ui.tsx @@ -2,8 +2,6 @@ import { JumpAction } from '@axonivy/process-editor-protocol'; import { IvyIcons } from '@axonivy/ui-icons'; import { Action, - EditorContextService, - GLSPAbstractUIExtension, IActionDispatcher, IActionHandler, SelectionService, @@ -13,15 +11,18 @@ import { UpdateModelAction } from '@eclipse-glsp/client'; import { inject, injectable } from 'inversify'; -import { createElement, createIcon } from '../utils/ui-utils'; +import { ReactUIExtension } from '../utils/react-ui-extension'; +import React from 'react'; +import IvyIcon from '../utils/ui-components'; + +const JSX = { createElement: React.createElement }; @injectable() -export class JumpOutUi extends GLSPAbstractUIExtension implements IActionHandler { +export class JumpOutUi extends ReactUIExtension implements IActionHandler { static readonly ID = 'jumpOutUi'; @inject(TYPES.IActionDispatcher) protected readonly actionDispatcher: IActionDispatcher; @inject(SelectionService) protected selectionService: SelectionService; - @inject(EditorContextService) protected readonly editorContext: EditorContextService; id(): string { return JumpOutUi.ID; @@ -31,17 +32,17 @@ export class JumpOutUi extends GLSPAbstractUIExtension implements IActionHandler return 'jump-out-container'; } - override initializeContents(containerElement: HTMLElement): void { - containerElement.style.position = 'absolute'; + protected initializeContainer(container: HTMLElement): void { + super.initializeContainer(container); + container.style.position = 'absolute'; } - override onBeforeShow(containerElement: HTMLElement) { - containerElement.innerHTML = ''; - const button = createElement('button', ['jump-out-btn']); - button.title = 'Jump out (J)'; - button.appendChild(createIcon(IvyIcons.JumpOut)); - button.onclick = () => this.actionDispatcher.dispatch(JumpAction.create({ elementId: '' })); - containerElement.appendChild(button); + protected render(): React.ReactNode { + return ( + + ); } handle(action: Action): void { diff --git a/packages/editor/src/ui-tools/tool-bar/tool-bar.ts b/packages/editor/src/ui-tools/tool-bar/tool-bar.tsx similarity index 80% rename from packages/editor/src/ui-tools/tool-bar/tool-bar.ts rename to packages/editor/src/ui-tools/tool-bar/tool-bar.tsx index fb1d17c30..217c0baf3 100644 --- a/packages/editor/src/ui-tools/tool-bar/tool-bar.ts +++ b/packages/editor/src/ui-tools/tool-bar/tool-bar.tsx @@ -1,8 +1,6 @@ import { - GLSPAbstractUIExtension, Action, DisposableCollection, - EditorContextService, EnableDefaultToolsAction, EnableToolPaletteAction, IActionHandler, @@ -36,16 +34,21 @@ import { ShowToolBarOptionsMenuAction } from './options/action'; import { ToolBarOptionsMenu } from './options/options-menu-ui'; import { ShowToolBarMenuAction, ToolBarMenu } from './tool-bar-menu'; import { UpdatePaletteItems } from '@axonivy/process-editor-protocol'; +import { ReactUIExtension } from '../../utils/react-ui-extension'; +import { SModelRootImpl } from 'sprotty'; +import React from 'react'; +import IvyIcon from '../../utils/ui-components'; + +const JSX = { createElement: React.createElement }; const CLICKED_CSS_CLASS = 'clicked'; @injectable() -export class ToolBar extends GLSPAbstractUIExtension implements IActionHandler, IEditModeListener, ISelectionListener { +export class ToolBar extends ReactUIExtension implements IActionHandler, IEditModeListener, ISelectionListener { static readonly ID = 'ivy-tool-bar'; @inject(TYPES.IActionDispatcher) protected readonly actionDispatcher: IActionDispatcher; @inject(SelectionService) protected readonly selectionService: SelectionService; - @inject(EditorContextService) protected readonly editorContext: EditorContextService; @multiInject(IVY_TYPES.ToolBarButtonProvider) protected toolBarButtonProvider: ToolBarButtonProvider[]; protected lastActivebutton?: HTMLElement; @@ -72,12 +75,14 @@ export class ToolBar extends GLSPAbstractUIExtension implements IActionHandler, } protected initializeContents(containerElement: HTMLElement) { + super.initializeContents(containerElement); this.createHeader(); this.lastActivebutton = this.defaultToolsButton; containerElement.onwheel = ev => (ev.ctrlKey ? ev.preventDefault() : true); } - protected onBeforeShow() { + protected onBeforeShow(containerElement: HTMLElement, root: Readonly, ...contextElementIds: string[]): void { + super.onBeforeShow(containerElement, root, ...contextElementIds); this.toDisposeOnHide.push(this.selectionService.onSelectionChanged(() => this.selectionChanged())); } @@ -86,6 +91,46 @@ export class ToolBar extends GLSPAbstractUIExtension implements IActionHandler, this.toDisposeOnHide.dispose(); } + protected render(): React.ReactNode { + return ( +
+
+ + +
+
+ {this.toolBarButtonProvider + .map(provider => provider.button()) + .filter(isNotUndefined) + .filter(button => button.location === ToolBarButtonLocation.Middle) + .filter(button => !this.editorContext.isReadonly || button.readonly) + .sort(compareButtons) + .map(button => ( + + ))} +
+
+ ); + } + protected createHeader(): void { const headerCompartment = createElement('div', ['tool-bar-header']); headerCompartment.appendChild(this.createLeftButtons()); diff --git a/packages/editor/src/ui-tools/viewport/viewport-bar.ts b/packages/editor/src/ui-tools/viewport/viewport-bar.ts deleted file mode 100644 index 59d696651..000000000 --- a/packages/editor/src/ui-tools/viewport/viewport-bar.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { - Action, - EditorContextService, - IActionHandler, - isViewport, - IToolManager, - SetUIExtensionVisibilityAction, - SetViewportAction, - TYPES, - SelectionService, - GLSPAbstractUIExtension, - type IActionDispatcher -} from '@eclipse-glsp/client'; -import { inject, injectable } from 'inversify'; -import { CenterButton, FitToScreenButton, OriginScreenButton, ViewportBarButton } from './button'; - -import { createElement, createIcon } from '../../utils/ui-utils'; -import { QuickActionUI } from '../quick-action/quick-action-ui'; -import { EnableViewportAction, SetViewportZoomAction } from '@axonivy/process-editor-protocol'; - -@injectable() -export class ViewportBar extends GLSPAbstractUIExtension implements IActionHandler { - static readonly ID = 'ivy-viewport-bar'; - - @inject(TYPES.IActionDispatcher) protected readonly actionDispatcher: IActionDispatcher; - @inject(TYPES.IToolManager) protected readonly toolManager: IToolManager; - @inject(EditorContextService) protected readonly editorContext: EditorContextService; - @inject(SelectionService) protected selectionService: SelectionService; - - protected zoomLevel = '100%'; - protected zoomLevelElement?: HTMLElement; - - id(): string { - return ViewportBar.ID; - } - containerClass(): string { - return ViewportBar.ID; - } - - protected initializeContents(containerElement: HTMLElement): void { - this.createBar(); - containerElement.onwheel = ev => (ev.ctrlKey ? ev.preventDefault() : true); - } - - protected createBar(): void { - const headerCompartment = createElement('div', ['viewport-bar']); - headerCompartment.appendChild(this.createViewportTools()); - this.containerElement.appendChild(headerCompartment); - } - - private createViewportTools(): HTMLElement { - const viewportTools = createElement('div', ['viewport-bar-tools']); - - viewportTools.appendChild(this.createViewportButton(new OriginScreenButton())); - viewportTools.appendChild(this.createViewportButton(new FitToScreenButton())); - viewportTools.appendChild(this.createViewportButton(new CenterButton(() => [...this.selectionService.getSelectedElementIDs()]))); - - this.zoomLevelElement = document.createElement('label'); - this.zoomLevelElement.textContent = this.zoomLevel; - viewportTools.appendChild(this.zoomLevelElement); - return viewportTools; - } - - protected createViewportButton(toolButton: ViewportBarButton): HTMLElement { - const button = createElement('button'); - button.appendChild(createIcon(toolButton.icon)); - button.id = toolButton.id; - button.title = toolButton.title; - button.onclick = () => - this.actionDispatcher.dispatch(toolButton.action()).then(() => { - const model = this.editorContext.modelRoot; - if (isViewport(model)) { - this.actionDispatcher.dispatchAll([ - SetUIExtensionVisibilityAction.create({ - extensionId: QuickActionUI.ID, - visible: true, - contextElementsId: [...this.selectionService.getSelectedElementIDs()] - }), - SetViewportAction.create(model.id, model, {}) - ]); - } - }); - return button; - } - - handle(action: Action) { - if (EnableViewportAction.is(action)) { - this.actionDispatcher.dispatch(SetUIExtensionVisibilityAction.create({ extensionId: ViewportBar.ID, visible: true })); - } - if (SetViewportAction.is(action)) { - this.updateZoomLevel(action.newViewport.zoom); - } else if (SetViewportZoomAction.is(action)) { - this.updateZoomLevel(action.zoom); - } - } - - private updateZoomLevel(zoom: number): void { - this.zoomLevel = (zoom * 100).toFixed(0).toString() + '%'; - if (this.zoomLevelElement) { - this.zoomLevelElement.textContent = this.zoomLevel; - } - } -} diff --git a/packages/editor/src/ui-tools/viewport/viewport-bar.tsx b/packages/editor/src/ui-tools/viewport/viewport-bar.tsx new file mode 100644 index 000000000..dd8e4693f --- /dev/null +++ b/packages/editor/src/ui-tools/viewport/viewport-bar.tsx @@ -0,0 +1,102 @@ +import { + Action, + IActionHandler, + isViewport, + IToolManager, + SetUIExtensionVisibilityAction, + SetViewportAction, + TYPES, + SelectionService, + type IActionDispatcher +} from '@eclipse-glsp/client'; +import { inject, injectable } from 'inversify'; +import { CenterButton, FitToScreenButton, OriginScreenButton, ViewportBarButton } from './button'; + +import { QuickActionUI } from '../quick-action/quick-action-ui'; +import { EnableViewportAction, SetViewportZoomAction } from '@axonivy/process-editor-protocol'; +import { ReactUIExtension } from '../../utils/react-ui-extension'; +import React from 'react'; +import IvyIcon from '../../utils/ui-components'; + +const JSX = { createElement: React.createElement }; + +@injectable() +export class ViewportBar extends ReactUIExtension implements IActionHandler { + static readonly ID = 'ivy-viewport-bar'; + + @inject(TYPES.IActionDispatcher) protected readonly actionDispatcher: IActionDispatcher; + @inject(TYPES.IToolManager) protected readonly toolManager: IToolManager; + @inject(SelectionService) protected selectionService: SelectionService; + + protected zoomLevel = '100%'; + protected zoomLevelElement?: HTMLElement; + + id(): string { + return ViewportBar.ID; + } + containerClass(): string { + return ViewportBar.ID; + } + + protected initializeContainer(container: HTMLElement): void { + super.initializeContainer(container); + container.onwheel = ev => (ev.ctrlKey ? ev.preventDefault() : true); + } + + protected render(): React.ReactNode { + return ( +
+
+ {this.createViewportButton(new OriginScreenButton())} + {this.createViewportButton(new FitToScreenButton())} + {this.createViewportButton(new CenterButton(() => [...this.selectionService.getSelectedElementIDs()]))} + +
+
+ ); + } + + protected createViewportButton(toolButton: ViewportBarButton): React.ReactNode { + return ( + + ); + } + + handle(action: Action) { + if (EnableViewportAction.is(action)) { + this.actionDispatcher.dispatch(SetUIExtensionVisibilityAction.create({ extensionId: ViewportBar.ID, visible: true })); + } + if (SetViewportAction.is(action)) { + this.updateZoomLevel(action.newViewport.zoom); + } else if (SetViewportZoomAction.is(action)) { + this.updateZoomLevel(action.zoom); + } + } + + private updateZoomLevel(zoom: number): void { + this.zoomLevel = (zoom * 100).toFixed(0).toString() + '%'; + if (this.zoomLevelElement) { + this.zoomLevelElement.textContent = this.zoomLevel; + } + } +} diff --git a/packages/editor/src/utils/react-ui-extension.tsx b/packages/editor/src/utils/react-ui-extension.tsx new file mode 100644 index 000000000..5a5027983 --- /dev/null +++ b/packages/editor/src/utils/react-ui-extension.tsx @@ -0,0 +1,39 @@ +import * as React from 'react'; +import { EditorContextService, GLSPAbstractUIExtension } from '@eclipse-glsp/client'; +import { inject, injectable } from 'inversify'; +import { createRoot, Root } from 'react-dom/client'; +import { SModelRootImpl } from 'sprotty'; + +const JSX = { createElement: React.createElement }; + +@injectable() +export abstract class ReactUIExtension extends GLSPAbstractUIExtension { + @inject(EditorContextService) protected editorContext: EditorContextService; + + protected nodeRoot: Root; + protected currentRoot: Readonly; + protected currentContextElementIds: string[]; + + protected initializeContents(containerElement: HTMLElement): void { + this.nodeRoot = createRoot(containerElement); + // once initialized and added to the DOM, we do not remove the UI extension from the DOM again + // if we were to do that, we should make sure to call this.nodeRoot.unmount() + } + + protected abstract render(root: Readonly, ...contextElementIds: string[]): React.ReactNode; + + protected onBeforeShow(containerElement: HTMLElement, root: Readonly, ...contextElementIds: string[]): void { + this.currentRoot = root; + this.currentContextElementIds = contextElementIds; + super.onBeforeShow(containerElement, root, ...contextElementIds); + this.update(); + } + + protected update(): void { + const root = this.currentRoot ?? this.editorContext.modelRoot; + const contextElementIds = this.currentContextElementIds ?? []; + if (this.nodeRoot) { + this.nodeRoot.render({this.render(root, ...contextElementIds)}); + } + } +} diff --git a/packages/editor/src/utils/ui-components.tsx b/packages/editor/src/utils/ui-components.tsx new file mode 100644 index 000000000..422caa963 --- /dev/null +++ b/packages/editor/src/utils/ui-components.tsx @@ -0,0 +1,35 @@ +import React, { PropsWithChildren } from 'react'; + +const JSX = { createElement: React.createElement }; + +interface IconProps { + icon?: string; + additionalClasses?: string[]; + // You can also allow passing other props like className, style, etc. + className?: string; +} + +const IvyIcon: React.FC> = ({ icon, additionalClasses = [], className = '', children }) => { + // Initialize the class list + const cssClasses = ['ivy']; + + if (icon) { + cssClasses.push(`ivy-${icon}`); + } + + if (additionalClasses && additionalClasses.length > 0) { + cssClasses.push(...additionalClasses); + } + + // If there's a className prop, include it + if (className) { + cssClasses.push(className); + } + + // Join all classes into a single string + const finalClassName = cssClasses.join(' '); + + return {children}; +}; + +export default IvyIcon; diff --git a/packages/inscription/src/inscription/inscription-ui.tsx b/packages/inscription/src/inscription/inscription-ui.tsx index 1e86a6654..6242e02e7 100644 --- a/packages/inscription/src/inscription/inscription-ui.tsx +++ b/packages/inscription/src/inscription/inscription-ui.tsx @@ -5,7 +5,6 @@ import { JumpAction, MoveIntoViewportAction, SwitchThemeAction } from '@axonivy/ import { Action, GArgument, - GLSPAbstractUIExtension, GModelRoot, IActionHandler, ISelectionListener, @@ -22,14 +21,14 @@ import type { MonacoLanguageClient } from 'monaco-languageclient'; import { QueryClient } from '@tanstack/react-query'; import { inject, injectable, postConstruct } from 'inversify'; -import { Root, createRoot } from 'react-dom/client'; import { OpenAction } from 'sprotty-protocol'; import InscriptionView from './InscriptionView'; import { EnableInscriptionAction, ToggleInscriptionAction } from './action'; import * as React from 'react'; +import { ReactUIExtension } from './react-ui-extension'; @injectable() -export class InscriptionUi extends GLSPAbstractUIExtension implements IActionHandler, ISelectionListener { +export class InscriptionUi extends ReactUIExtension implements IActionHandler, ISelectionListener { static readonly ID = 'inscription-ui'; @inject(SelectionService) protected readonly selectionService: SelectionService; @@ -38,8 +37,8 @@ export class InscriptionUi extends GLSPAbstractUIExtension implements IActionHan private inscriptionElement?: string; private action?: EnableInscriptionAction; private inscriptionContext: InscriptionContext; - private root: Root; private inscriptionClient?: Promise; + private resolvedInscriptionClient?: InscriptionClientJsonRpc; private queryClient: QueryClient; public id(): string { @@ -55,38 +54,45 @@ export class InscriptionUi extends GLSPAbstractUIExtension implements IActionHan return 'inscription-ui-container'; } + protected resolveInscriptionClient(client: InscriptionClientJsonRpc) { + this.resolvedInscriptionClient = client; + return client; + } + protected initializeContents(containerElement: HTMLElement) { + super.initializeContents(containerElement); this.changeUiVisiblitiy(false); this.inscriptionContext = this.initInscriptionContext(); this.queryClient = initQueryClient(); - this.inscriptionClient = this.startInscriptionClient(); - this.root = createRoot(containerElement); + this.inscriptionClient = this.startInscriptionClient().then(client => this.resolveInscriptionClient(client)); } - private updateInscriptionUi() { - if (!this.inscriptionElement) { + protected render(): React.ReactNode { + const element = this.inscriptionElement; + if (!element) { return; } - const element = this.inscriptionElement; - this.inscriptionClient?.then(client => { - this.root.render( - - - - this.selectFromOutline(id) - }} - /> - - - - ); - }); + if (!this.resolvedInscriptionClient) { + this.inscriptionClient?.then(() => this.update()); + return; + } + return ( + + + + this.selectFromOutline(id) + }} + /> + + + + ); } async startInscriptionClient(): Promise { @@ -122,7 +128,7 @@ export class InscriptionUi extends GLSPAbstractUIExtension implements IActionHan private async startInscriptionWebSocketClient(webSocketAddress: string) { const initInscription = async (connection: Connection) => { - this.inscriptionClient = InscriptionClientJsonRpc.startClient(connection); + this.inscriptionClient = InscriptionClientJsonRpc.startClient(connection).then(client => this.resolveInscriptionClient(client)); return this.inscriptionClient; }; const reconnectInscription = async (connection: Connection, oldClient: InscriptionClientJsonRpc) => { @@ -148,7 +154,7 @@ export class InscriptionUi extends GLSPAbstractUIExtension implements IActionHan if (ToggleInscriptionAction.is(action)) { if (!this.inscriptionElement) { this.inscriptionElement = this.selectionService.getModelRoot().id; - this.updateInscriptionUi(); + this.update(); } this.changeUiVisiblitiy(action.force); } @@ -156,7 +162,7 @@ export class InscriptionUi extends GLSPAbstractUIExtension implements IActionHan this.changeUiVisiblitiy(true); } if (SwitchThemeAction.is(action)) { - this.updateInscriptionUi(); + this.update(); MonacoEditorUtil.setTheme(action.theme); } return; @@ -195,7 +201,7 @@ export class InscriptionUi extends GLSPAbstractUIExtension implements IActionHan this.changeUiVisiblitiy(false); } } - this.updateInscriptionUi(); + this.update(); } private isOutlineOpen() { diff --git a/packages/inscription/src/inscription/react-ui-extension.tsx b/packages/inscription/src/inscription/react-ui-extension.tsx new file mode 100644 index 000000000..808225bd0 --- /dev/null +++ b/packages/inscription/src/inscription/react-ui-extension.tsx @@ -0,0 +1,37 @@ +import * as React from 'react'; +import { EditorContextService, GLSPAbstractUIExtension } from '@eclipse-glsp/client'; +import { inject, injectable } from 'inversify'; +import { createRoot, Root } from 'react-dom/client'; +import { SModelRootImpl } from 'sprotty'; + +@injectable() +export abstract class ReactUIExtension extends GLSPAbstractUIExtension { + @inject(EditorContextService) protected editorContext: EditorContextService; + + protected nodeRoot: Root; + protected currentRoot: Readonly; + protected currentContextElementIds: string[]; + + protected initializeContents(containerElement: HTMLElement): void { + this.nodeRoot = createRoot(containerElement); + // once initialized and added to the DOM, we do not remove the UI extension from the DOM again + // if we were to do that, we should make sure to call this.nodeRoot.unmount() + } + + protected abstract render(root: Readonly, ...contextElementIds: string[]): React.ReactNode; + + protected onBeforeShow(containerElement: HTMLElement, root: Readonly, ...contextElementIds: string[]): void { + this.currentRoot = root; + this.currentContextElementIds = contextElementIds; + super.onBeforeShow(containerElement, root, ...contextElementIds); + this.update(); + } + + protected update(): void { + const root = this.currentRoot ?? this.editorContext.modelRoot; + const contextElementIds = this.currentContextElementIds ?? []; + if (this.nodeRoot) { + this.nodeRoot.render({this.render(root, ...contextElementIds)}); + } + } +}