diff --git a/examples/workflow-glsp/src/index.ts b/examples/workflow-glsp/src/index.ts index d67a397f5..f5d9dc1eb 100644 --- a/examples/workflow-glsp/src/index.ts +++ b/examples/workflow-glsp/src/index.ts @@ -16,4 +16,6 @@ export * from './direct-task-editing'; export * from './model'; export * from './workflow-diagram-module'; +export * from './workflow-snapper'; +export * from './workflow-startup'; export * from './workflow-views'; diff --git a/examples/workflow-glsp/src/workflow-diagram-module.ts b/examples/workflow-glsp/src/workflow-diagram-module.ts index 70bab3817..81b670fd2 100644 --- a/examples/workflow-glsp/src/workflow-diagram-module.ts +++ b/examples/workflow-glsp/src/workflow-diagram-module.ts @@ -27,11 +27,8 @@ import { GLSPProjectionView, GLabel, GLabelView, - GridSnapper, IHelperLineOptions, - ISnapper, LogLevel, - Point, RectangularNodeView, RevealNamedElementActionProvider, RoundedCornerNodeView, @@ -41,7 +38,9 @@ import { bindOrRebind, configureDefaultModelElements, configureModelElement, + debugModule, editLabelFeature, + gridModule, helperLineModule, initializeDiagramContainer } from '@eclipse-glsp/client'; @@ -51,6 +50,8 @@ import 'sprotty/css/edit-label.css'; import '../css/diagram.css'; import { directTaskEditor } from './direct-task-editing/di.config'; import { ActivityNode, CategoryNode, Icon, TaskNode, WeightedEdge } from './model'; +import { WorkflowSnapper } from './workflow-snapper'; +import { WorkflowStartup } from './workflow-startup'; import { IconView, WorkflowEdgeView } from './workflow-views'; export const workflowDiagramModule = new ContainerModule((bind, unbind, isBound, rebind) => { @@ -58,7 +59,6 @@ export const workflowDiagramModule = new ContainerModule((bind, unbind, isBound, bindOrRebind(context, TYPES.ILogger).to(ConsoleLogger).inSingletonScope(); bindOrRebind(context, TYPES.LogLevel).toConstantValue(LogLevel.warn); - bind(TYPES.ISnapper).to(GridSnapper); bindAsService(context, TYPES.ICommandPaletteActionProvider, RevealNamedElementActionProvider); bindAsService(context, TYPES.IContextMenuItemProvider, DeleteElementContextMenuItemProvider); @@ -82,16 +82,14 @@ export const workflowDiagramModule = new ContainerModule((bind, unbind, isBound, bind(TYPES.IHelperLineOptions).toDynamicValue(ctx => { const options: IHelperLineOptions = {}; - // the user needs to use twice the force (double the distance) to break through a helper line compared to moving on the grid - const snapper = ctx.container.get(TYPES.ISnapper); - if (snapper instanceof GridSnapper) { - options.minimumMoveDelta = Point.multiplyScalar(snapper.grid, 2); - } // skip icons for alignment as well as compartments which are only used for structure options.alignmentElementFilter = element => DEFAULT_ALIGNABLE_ELEMENT_FILTER(element) && !(element instanceof Icon) && !(element instanceof GCompartment); return options; }); + + bindAsService(context, TYPES.IDiagramStartup, WorkflowStartup); + bindOrRebind(context, TYPES.ISnapper).to(WorkflowSnapper); }); export function createWorkflowDiagramContainer(...containerConfiguration: ContainerConfiguration): Container { @@ -99,5 +97,13 @@ export function createWorkflowDiagramContainer(...containerConfiguration: Contai } export function initializeWorkflowDiagramContainer(container: Container, ...containerConfiguration: ContainerConfiguration): Container { - return initializeDiagramContainer(container, workflowDiagramModule, directTaskEditor, helperLineModule, ...containerConfiguration); + return initializeDiagramContainer( + container, + directTaskEditor, + helperLineModule, + gridModule, + debugModule, + workflowDiagramModule, + ...containerConfiguration + ); } diff --git a/examples/workflow-glsp/src/workflow-snapper.ts b/examples/workflow-glsp/src/workflow-snapper.ts new file mode 100644 index 000000000..4c71fd261 --- /dev/null +++ b/examples/workflow-glsp/src/workflow-snapper.ts @@ -0,0 +1,29 @@ +/******************************************************************************** + * Copyright (c) 2024 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { GModelElement, GRoutingHandle, GridSnapper, Point } from '@eclipse-glsp/client'; +import { injectable } from 'inversify'; + +@injectable() +export class WorkflowSnapper extends GridSnapper { + override snap(position: Point, element: GModelElement): Point { + // we snap our edges to the center of the elements and our elements to the grid, + // so to allow for nicer angles and more fine-grained control, we allow routing points to be snapped half-grid + return element instanceof GRoutingHandle + ? Point.snapToGrid(position, Point.divideScalar(this.grid, 2)) + : super.snap(position, element); + } +} diff --git a/examples/workflow-glsp/src/workflow-startup.ts b/examples/workflow-glsp/src/workflow-startup.ts new file mode 100644 index 000000000..7004323ff --- /dev/null +++ b/examples/workflow-glsp/src/workflow-startup.ts @@ -0,0 +1,30 @@ +/******************************************************************************** + * Copyright (c) 2024 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { GridManager, IDiagramStartup } from '@eclipse-glsp/client'; +import { MaybePromise, TYPES } from '@eclipse-glsp/sprotty'; +import { inject, injectable, optional } from 'inversify'; + +@injectable() +export class WorkflowStartup implements IDiagramStartup { + rank = -1; + + @inject(TYPES.IGridManager) @optional() protected gridManager?: GridManager; + + preRequestModel(): MaybePromise { + this.gridManager?.setGridVisible(true); + } +} diff --git a/examples/workflow-standalone/css/diagram.css b/examples/workflow-standalone/css/diagram.css index bc379b976..782f1d524 100644 --- a/examples/workflow-standalone/css/diagram.css +++ b/examples/workflow-standalone/css/diagram.css @@ -17,7 +17,8 @@ --glsp-info-foreground: blue; } -.sprotty-graph { +.sprotty-graph, +.grid-background { background: rgb(179, 196, 202); } diff --git a/packages/client/css/debug.css b/packages/client/css/debug.css new file mode 100644 index 000000000..8c1ace404 --- /dev/null +++ b/packages/client/css/debug.css @@ -0,0 +1,25 @@ +/******************************************************************************** + * Copyright (c) 2024 Axon Ivy AG and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +.debug-bounds:has(> .debug-bounds-decoration) { + fill-opacity: 0.5; +} + +.debug-bounds-decoration { + fill: none; + stroke: black; + stroke-width: 1px; +} diff --git a/packages/client/css/grid.css b/packages/client/css/grid.css new file mode 100644 index 000000000..8cad8ef26 --- /dev/null +++ b/packages/client/css/grid.css @@ -0,0 +1,20 @@ +/******************************************************************************** + * Copyright (c) 2024 Axon Ivy AG and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +/** Control visibility of background image through CSS class on parent */ +.grid-background .sprotty-graph { + background-image: var(--grid-background-image); +} diff --git a/packages/client/css/keyboard-tool-palette.css b/packages/client/css/keyboard-tool-palette.css index 2ce9fa8b2..5e31b6d70 100644 --- a/packages/client/css/keyboard-tool-palette.css +++ b/packages/client/css/keyboard-tool-palette.css @@ -14,13 +14,7 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -.accessibility-tool-palette.tool-palette { - top: 48px; - width: 240px; -} - .accessibility-tool-palette .header-tools i { - margin-right: 0.5em; position: relative; } diff --git a/packages/client/css/tool-palette.css b/packages/client/css/tool-palette.css index d92f46884..b1ab6e572 100644 --- a/packages/client/css/tool-palette.css +++ b/packages/client/css/tool-palette.css @@ -17,10 +17,10 @@ /* Css for main container */ .tool-palette { position: absolute; - right: 40px; - top: 25px; + right: 45px; + top: 35px; text-align: center; - width: 225px; + width: fit-content; display: block; z-index: 1000; border-style: solid; @@ -38,6 +38,11 @@ /* Firefox */ -ms-user-select: none; /* Internet Explorer/Edge */ + + box-shadow: + 4px 0 4px 0 rgba(0, 0, 0, 0.2), + 0 4px 4px 0 rgba(0, 0, 0, 0.19); + font-size: 14px; } /* Css for header compartment */ @@ -45,33 +50,29 @@ .palette-header { padding: 0.4em; text-align: left; - background: #cccccc; + background: rgb(151, 160, 165); border: 1px solid rgba(60, 60, 60, 0.6); - box-shadow: - 0 4px 8px 0 rgba(0, 0, 0, 0.2), - 0 6px 20px 0 rgba(0, 0, 0, 0.19); + border-bottom: 0; display: flex; + flex-direction: column; align-items: center; justify-content: space-between; - flex-wrap: wrap; + gap: 6px; } .header-icon { - display: flex; + display: none; align-items: center; } -.header-icon i { - margin-right: 0.2em; -} - .header-tools { display: flex; align-items: center; + gap: 3px; } .header-tools i { - border: 1px solid #cccccc; + border: 1px solid transparent; padding: 0.15em; margin-right: 0.15em; } @@ -90,9 +91,6 @@ .palette-body { background: rgba(100, 100, 100, 0.2); border: 1px solid rgba(60, 60, 60, 0.6); - box-shadow: - 0 4px 8px 0 rgba(0, 0, 0, 0.2), - 0 6px 20px 0 rgba(0, 0, 0, 0.19); } .tool-group { @@ -101,13 +99,13 @@ } .group-header { - background: #cccccc; + background: rgb(187, 193, 196); display: flex; align-items: center; } .group-header:hover { - background: #aaaaaa; + background: rgb(187, 193, 196); } .group-header i { @@ -115,7 +113,7 @@ } .tool-button { - background: #ededee; + background: rgb(252, 253, 253); padding: 0.4em; display: flex; align-items: center; @@ -126,11 +124,11 @@ } .tool-button:hover { - background: #dfdfdf; + background: rgb(220, 223, 224); } .tool-button.clicked { - background: #bddaef; + background: rgb(163, 201, 219); } .tool-button.collapsed { @@ -147,13 +145,19 @@ right: 20px; top: 35px; z-index: 1000; + color: rgb(58, 63, 65); +} + +.minimize-palette-button .codicon::before { + font-size: 20px; } .search-input { - background: #dfdfdf; + box-sizing: border-box; + background: rgb(252, 253, 253); color: black; - border: #bddaef; - padding-left: 3px; + border: 1px solid rgb(187, 193, 196); + padding: 4px; width: 100%; - margin: 3px; + margin: 3px 0; } diff --git a/packages/client/src/features/accessibility/keyboard-tool-palette/keyboard-tool-palette.ts b/packages/client/src/features/accessibility/keyboard-tool-palette/keyboard-tool-palette.ts index a20e0d272..e8984cc8b 100644 --- a/packages/client/src/features/accessibility/keyboard-tool-palette/keyboard-tool-palette.ts +++ b/packages/client/src/features/accessibility/keyboard-tool-palette/keyboard-tool-palette.ts @@ -44,7 +44,7 @@ import * as messages from '../toast/messages.json'; import { ShowToastMessageAction } from '../toast/toast-handler'; const SEARCH_ICON_ID = 'search'; -const PALETTE_ICON_ID = 'symbol-color'; +const PALETTE_ICON_ID = 'tools'; const CHEVRON_DOWN_ICON_ID = 'chevron-right'; const PALETTE_HEIGHT = '500px'; const SELECTION_TOOL_KEY: KeyCode[] = ['Digit1', 'Numpad1']; @@ -212,29 +212,46 @@ export class KeyboardToolPalette extends ToolPalette { protected override createHeaderTools(): HTMLElement { this.headerToolsButtonMapping.clear(); + let mappingIndex = 0; const headerTools = document.createElement('div'); headerTools.classList.add('header-tools'); this.defaultToolsButton = this.createDefaultToolButton(); - this.headerToolsButtonMapping.set(0, this.defaultToolsButton); + this.headerToolsButtonMapping.set(mappingIndex++, this.defaultToolsButton); headerTools.appendChild(this.defaultToolsButton); this.deleteToolButton = this.createMouseDeleteToolButton(); - this.headerToolsButtonMapping.set(1, this.deleteToolButton); + this.headerToolsButtonMapping.set(mappingIndex++, this.deleteToolButton); headerTools.appendChild(this.deleteToolButton); this.marqueeToolButton = this.createMarqueeToolButton(); - this.headerToolsButtonMapping.set(2, this.marqueeToolButton); + this.headerToolsButtonMapping.set(mappingIndex++, this.marqueeToolButton); headerTools.appendChild(this.marqueeToolButton); this.validateToolButton = this.createValidateButton(); - this.headerToolsButtonMapping.set(3, this.validateToolButton); + this.headerToolsButtonMapping.set(mappingIndex++, this.validateToolButton); headerTools.appendChild(this.validateToolButton); + const resetViewportButton = this.createResetViewportButton(); + this.headerToolsButtonMapping.set(mappingIndex++, resetViewportButton); + headerTools.appendChild(resetViewportButton); + + if (this.gridManager) { + const toggleGridButton = this.createToggleGridButton(); + this.headerToolsButtonMapping.set(mappingIndex++, toggleGridButton); + headerTools.appendChild(toggleGridButton); + } + + if (this.debugManager) { + const toggleDebugButton = this.createToggleDebugButton(); + this.headerToolsButtonMapping.set(mappingIndex++, toggleDebugButton); + headerTools.appendChild(toggleDebugButton); + } + // Create button for Search this.searchToolButton = this.createSearchButton(); - this.headerToolsButtonMapping.set(4, this.searchToolButton); + this.headerToolsButtonMapping.set(mappingIndex++, this.searchToolButton); headerTools.appendChild(this.searchToolButton); return headerTools; @@ -324,7 +341,7 @@ export class KeyboardToolPalette extends ToolPalette { searchField.tabIndex = 21; searchField.id = this.containerElement.id + '_search_field'; searchField.type = 'text'; - searchField.placeholder = ' Search...'; + searchField.placeholder = 'Search...'; searchField.style.display = 'none'; searchField.onkeyup = ev => { this.requestFilterUpdate(this.searchField.value); diff --git a/packages/client/src/features/accessibility/resize-key-tool/resize-key-handler.ts b/packages/client/src/features/accessibility/resize-key-tool/resize-key-handler.ts index fa4aef167..d9e225105 100644 --- a/packages/client/src/features/accessibility/resize-key-tool/resize-key-handler.ts +++ b/packages/client/src/features/accessibility/resize-key-tool/resize-key-handler.ts @@ -36,7 +36,7 @@ import { EditorContextService } from '../../../base/editor-context-service'; import { IFeedbackActionDispatcher } from '../../../base/feedback/feedback-action-dispatcher'; import { Resizable, SelectableBoundsAware, getElements, isSelectableAndBoundsAware, toElementAndBounds } from '../../../utils/gmodel-util'; import { isValidMove, isValidSize, minHeight, minWidth } from '../../../utils/layout-utils'; -import { GridSnapper } from '../../change-bounds/snap'; +import { Grid } from '../../grid'; export enum ResizeType { Increase, @@ -80,20 +80,20 @@ export class ResizeElementHandler implements IActionHandler { protected debouncedChangeBounds?: DebouncedFunc<() => void>; protected resizeFeedback: FeedbackEmitter; - // Default x resize used if GridSnapper is not provided + // Default x resize used if grid is not provided static readonly defaultResizeX = 20; - // Default y resize used if GridSnapper is not provided + // Default y resize used if grid is not provided static readonly defaultResizeY = 20; - protected grid = { x: ResizeElementHandler.defaultResizeX, y: ResizeElementHandler.defaultResizeY }; + + @inject(TYPES.Grid) @optional() protected grid: Grid = { + x: ResizeElementHandler.defaultResizeX, + y: ResizeElementHandler.defaultResizeY + }; protected isEditMode = false; - constructor(@inject(TYPES.ISnapper) @optional() protected readonly snapper?: ISnapper) { - if (snapper instanceof GridSnapper) { - this.grid = snapper.grid; - } - } + constructor(@inject(TYPES.ISnapper) @optional() protected readonly snapper?: ISnapper) {} @postConstruct() protected init(): void { diff --git a/packages/client/src/features/accessibility/view-key-tools/movement-key-tool.ts b/packages/client/src/features/accessibility/view-key-tools/movement-key-tool.ts index ddee99743..16497910c 100644 --- a/packages/client/src/features/accessibility/view-key-tools/movement-key-tool.ts +++ b/packages/client/src/features/accessibility/view-key-tools/movement-key-tool.ts @@ -20,7 +20,8 @@ import { matchesKeystroke } from 'sprotty/lib/utils/keyboard'; import { GLSPActionDispatcher } from '../../../base/action-dispatcher'; import { SelectionService } from '../../../base/selection-service'; import { Tool } from '../../../base/tool-manager/tool'; -import { GridSnapper, unsnapModifier, useSnap } from '../../change-bounds/snap'; +import { unsnapModifier, useSnap } from '../../change-bounds/snap'; +import { Grid } from '../../grid'; import { AccessibleKeyShortcutProvider, SetAccessibleKeyShortcutAction } from '../key-shortcut/accessible-key-shortcut'; import { MoveElementAction, MoveViewportAction } from '../move-zoom/move-handler'; @@ -33,18 +34,22 @@ export class MovementKeyTool implements Tool { isEditTool = true; - protected readonly movementKeyListener = new MoveKeyListener(this); + protected movementKeyListener: MoveKeyListener; @inject(KeyTool) protected readonly keytool: KeyTool; @inject(SelectionService) selectionService: SelectionService; @inject(TYPES.ISnapper) @optional() readonly snapper?: ISnapper; @inject(TYPES.IActionDispatcher) readonly actionDispatcher: GLSPActionDispatcher; + @optional() @inject(TYPES.Grid) protected grid: Grid; get id(): string { return MovementKeyTool.ID; } enable(): void { + if (!this.movementKeyListener) { + this.movementKeyListener = new MoveKeyListener(this, this.grid); + } this.keytool.register(this.movementKeyListener); this.movementKeyListener.registerShortcutKey(); } @@ -55,22 +60,19 @@ export class MovementKeyTool implements Tool { } export class MoveKeyListener extends KeyListener implements AccessibleKeyShortcutProvider { - // Default x distance used if GridSnapper is not provided + // Default x distance used if grid is not provided static readonly defaultMoveX = 20; - // Default y distance used if GridSnapper is not provided + // Default y distance used if grid is not provided static readonly defaultMoveY = 20; protected readonly token = MoveKeyListener.name; - protected grid = { x: MoveKeyListener.defaultMoveX, y: MoveKeyListener.defaultMoveY }; - - constructor(protected readonly tool: MovementKeyTool) { + constructor( + protected readonly tool: MovementKeyTool, + protected grid: Grid = { x: MoveKeyListener.defaultMoveX, y: MoveKeyListener.defaultMoveY } + ) { super(); - - if (this.tool.snapper instanceof GridSnapper) { - this.grid = this.tool.snapper.grid; - } } registerShortcutKey(): void { diff --git a/packages/client/src/features/change-bounds/snap.spec.ts b/packages/client/src/features/change-bounds/point-position-updater.spec.ts similarity index 87% rename from packages/client/src/features/change-bounds/snap.spec.ts rename to packages/client/src/features/change-bounds/point-position-updater.spec.ts index ca6a82e8c..0d29442e9 100644 --- a/packages/client/src/features/change-bounds/snap.spec.ts +++ b/packages/client/src/features/change-bounds/point-position-updater.spec.ts @@ -16,19 +16,8 @@ import { GModelElement } from '@eclipse-glsp/sprotty'; import { expect } from 'chai'; +import { GridSnapper } from '../grid'; import { PointPositionUpdater } from './point-position-updater'; -import { GridSnapper } from './snap'; - -describe('GridSnapper', () => { - it('snap', () => { - const element = new GModelElement(); - const snapper = new GridSnapper(); - expect(snapper.snap({ x: 0, y: 0 }, element)).to.be.deep.equals({ x: 0, y: 0 }); - expect(snapper.snap({ x: 4, y: 5 }, element)).to.be.deep.equals({ x: 0, y: 10 }); - expect(snapper.snap({ x: 8, y: 11 }, element)).to.be.deep.equals({ x: 10, y: 10 }); - expect(snapper.snap({ x: -7, y: -4 }, element)).to.be.deep.equals({ x: -10, y: -0 }); - }); -}); describe('PointPositionUpdater', () => { it('updatePosition with no last drag position', () => { diff --git a/packages/client/src/features/change-bounds/snap.ts b/packages/client/src/features/change-bounds/snap.ts index 36f218caa..246c07c8c 100644 --- a/packages/client/src/features/change-bounds/snap.ts +++ b/packages/client/src/features/change-bounds/snap.ts @@ -14,30 +14,7 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ /* eslint-disable @typescript-eslint/no-shadow */ -import { GModelElement, ISnapper, KeyboardModifier, Point } from '@eclipse-glsp/sprotty'; -import { injectable } from 'inversify'; - -/** - * A {@link ISnapper} implementation that snaps all elements onto a fixed gride size. - * The default grid size is 10x10 pixel. - * To configure a custom grid size bind the `TYPES.ISnapper` service identifier - * to constant value, e.g: - * - * ```ts - * bind(TYPES.ISnapper).toConstantValue(new GridSnapper({x:25 ,y:25 })); - * ``` - */ -@injectable() -export class GridSnapper implements ISnapper { - constructor(public grid: { x: number; y: number } = { x: 10, y: 10 }) {} - - snap(position: Point, _element: GModelElement): Point { - return { - x: Math.round(position.x / this.grid.x) * this.grid.x, - y: Math.round(position.y / this.grid.y) * this.grid.y - }; - } -} +import { KeyboardModifier } from '@eclipse-glsp/sprotty'; export function useSnap(event: MouseEvent | KeyboardEvent): boolean { return !event.shiftKey; diff --git a/packages/client/src/features/debug/debug-bounds-decorator.tsx b/packages/client/src/features/debug/debug-bounds-decorator.tsx new file mode 100644 index 000000000..e59415136 --- /dev/null +++ b/packages/client/src/features/debug/debug-bounds-decorator.tsx @@ -0,0 +1,171 @@ +/******************************************************************************** + * Copyright (c) 2024 Axon Ivy AG and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ +/* eslint-disable max-len */ + +import { Bounds, GModelElement, IVNodePostprocessor, Point, TYPES, isBoundsAware, setClass, svg } from '@eclipse-glsp/sprotty'; +import { inject, injectable, optional } from 'inversify'; +import { VNode } from 'snabbdom'; +import { GGraph } from '../../model'; +import { BoundsAwareModelElement } from '../../utils'; +import { DebugManager } from './debug-manager'; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const JSX = { createElement: svg }; + +@injectable() +export class DebugBoundsDecorator implements IVNodePostprocessor { + @inject(TYPES.IDebugManager) @optional() protected debugManager?: DebugManager; + + decorate(vnode: VNode, element: GModelElement): VNode { + if (!this.debugManager?.isDebugEnabled) { + return vnode; + } + if (isBoundsAware(element)) { + this.decorateBoundsAware(vnode, element); + } + if (element instanceof GGraph) { + this.decorateGraph(vnode, element); + } + return vnode; + } + + postUpdate(): void {} + + protected get decorationSize(): number { + return 5; + } + + protected decorateGraph(vnode: VNode, graph: GGraph): void { + setClass(vnode, 'debug-bounds', true); + const svgChild = vnode.children?.find(child => typeof child !== 'string' && child.sel === 'svg') as VNode | undefined; + const group = svgChild?.children?.find(child => typeof child !== 'string' && child.sel === 'g') as VNode | undefined; + group?.children?.push(this.renderOrigin(graph)); + } + + protected renderOrigin(graph: GGraph): VNode { + return ( + + Origin = x: 0, y: 0 + + ); + } + + protected decorateBoundsAware(vnode: VNode, element: BoundsAwareModelElement): void { + setClass(vnode, 'debug-bounds', true); + vnode.children?.push(this.renderTopLeftCorner(element)); + vnode.children?.push(this.renderTopRightCorner(element)); + vnode.children?.push(this.renderBottomLeftCorner(element)); + vnode.children?.push(this.renderBottomRightCorner(element)); + vnode.children?.push(this.renderCenter(element)); + } + + protected renderTopLeftCorner(element: BoundsAwareModelElement): VNode { + const position = Bounds.topLeft(element.bounds); + const topLeft = Bounds.topLeft(element.bounds); + const corner = Point.subtract(topLeft, position); + return ( + + + Top Left = x: {topLeft.x}, y: {topLeft.y} + + + ); + } + + protected renderTopRightCorner(element: BoundsAwareModelElement): VNode { + const position = Bounds.topLeft(element.bounds); + const topRight = Bounds.topRight(element.bounds); + const corner = Point.subtract(topRight, position); + return ( + + + Top Right = x: {topRight.x}, y: {topRight.y} + + + ); + } + + protected renderBottomLeftCorner(element: BoundsAwareModelElement): VNode { + const position = Bounds.topLeft(element.bounds); + const bottomLeft = Bounds.bottomLeft(element.bounds); + const corner = Point.subtract(bottomLeft, position); + return ( + + + Bottom Left = x: {bottomLeft.x}, y: {bottomLeft.y} + + + ); + } + + protected renderBottomRightCorner(element: BoundsAwareModelElement): VNode { + const position = Bounds.topLeft(element.bounds); + const bottomRight = Bounds.bottomRight(element.bounds); + const corner = Point.subtract(bottomRight, position); + return ( + + + Bottom Right = x: {bottomRight.x}, y: {bottomRight.y} + + + ); + } + + protected renderCenter(element: BoundsAwareModelElement): VNode { + const bounds = element.bounds; + const position = Bounds.topLeft(bounds); + const center = Bounds.center(bounds); + const corner = Point.subtract(center, position); + return ( + + + Center = x: {center.x}, y: {center.y} + Bounds = x: {bounds.x}, y: {bounds.y}, width: {bounds.width}, height: {bounds.height} + + + + + ); + } +} diff --git a/packages/client/src/features/debug/debug-manager.ts b/packages/client/src/features/debug/debug-manager.ts new file mode 100644 index 000000000..ccd4775ca --- /dev/null +++ b/packages/client/src/features/debug/debug-manager.ts @@ -0,0 +1,56 @@ +/******************************************************************************** + * Copyright (c) 2024 Axon Ivy AG and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { IActionHandler, TYPES } from '@eclipse-glsp/sprotty'; +import { inject, injectable, postConstruct } from 'inversify'; +import { FeedbackEmitter, IFeedbackActionDispatcher } from '../../base'; +import { EnableDebugModeAction } from './debug-model'; + +@injectable() +export class DebugManager implements IActionHandler { + protected _debugEnabled: boolean = false; + protected debugFeedback: FeedbackEmitter; + + @inject(TYPES.IFeedbackActionDispatcher) + protected feedbackDispatcher: IFeedbackActionDispatcher; + + get isDebugEnabled(): boolean { + return this._debugEnabled; + } + + handle(action: EnableDebugModeAction): void { + this._debugEnabled = action.enable; + } + + @postConstruct() + protected init(): void { + this.debugFeedback = this.feedbackDispatcher.createEmitter(); + } + + setDebugEnabled(visible: boolean): void { + if (!visible) { + this.debugFeedback.dispose(); + } else { + this.debugFeedback + .add(EnableDebugModeAction.create({ enable: true }), EnableDebugModeAction.create({ enable: false })) + .submit(); + } + } + + toggleDebugEnabled(): void { + this.setDebugEnabled(!this._debugEnabled); + } +} diff --git a/packages/client/src/features/debug/debug-model.ts b/packages/client/src/features/debug/debug-model.ts new file mode 100644 index 000000000..cea8313d3 --- /dev/null +++ b/packages/client/src/features/debug/debug-model.ts @@ -0,0 +1,63 @@ +/******************************************************************************** + * Copyright (c) 2024 Axon Ivy AG and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { Action, CommandExecutionContext, CommandReturn, GModelRoot, TYPES, hasBooleanProp } from '@eclipse-glsp/sprotty'; +import { inject, injectable } from 'inversify'; +import { FeedbackCommand } from '../../base'; +import { addCssClasses, removeCssClasses } from '../../utils'; + +export interface EnableDebugModeAction extends Action { + kind: typeof EnableDebugModeAction.KIND; + enable: boolean; +} + +export namespace EnableDebugModeAction { + export const KIND = 'enableDebugMode'; + export const CSS_ROOT_CLASS = 'debug-mode'; + + export function is(object: any): object is EnableDebugModeAction { + return Action.hasKind(object, KIND) && hasBooleanProp(object, 'enable'); + } + + export function create(options: { enable: boolean }): EnableDebugModeAction { + return { + kind: EnableDebugModeAction.KIND, + ...options + }; + } +} + +@injectable() +export class EnableDebugModeCommand extends FeedbackCommand { + static readonly KIND = EnableDebugModeAction.KIND; + + constructor(@inject(TYPES.Action) protected readonly action: EnableDebugModeAction) { + super(); + } + + execute(context: CommandExecutionContext): CommandReturn { + return this.setDebugMode(context.root, this.action.enable); + } + + protected setDebugMode(root: GModelRoot, show: boolean): CommandReturn { + if (show) { + addCssClasses(root, [EnableDebugModeAction.CSS_ROOT_CLASS]); + } else { + removeCssClasses(root, [EnableDebugModeAction.CSS_ROOT_CLASS]); + } + return root; + } +} diff --git a/packages/client/src/features/debug/debug-module.ts b/packages/client/src/features/debug/debug-module.ts new file mode 100644 index 000000000..203558ad2 --- /dev/null +++ b/packages/client/src/features/debug/debug-module.ts @@ -0,0 +1,32 @@ +/******************************************************************************** + * Copyright (c) 2024 Axon Ivy AG and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { FeatureModule, TYPES, bindAsService, configureActionHandler, configureCommand } from '@eclipse-glsp/sprotty'; +import '../../../css/debug.css'; +import { DebugBoundsDecorator } from './debug-bounds-decorator'; +import { DebugManager } from './debug-manager'; +import { EnableDebugModeAction, EnableDebugModeCommand } from './debug-model'; + +export const debugModule = new FeatureModule((bind, unbind, isBound, rebind) => { + const context = { bind, unbind, isBound, rebind }; + + configureCommand(context, EnableDebugModeCommand); + + bindAsService(bind, TYPES.IDebugManager, DebugManager); + configureActionHandler(context, EnableDebugModeAction.KIND, DebugManager); + + bindAsService(context, TYPES.IVNodePostprocessor, DebugBoundsDecorator); +}); diff --git a/packages/client/src/features/debug/index.ts b/packages/client/src/features/debug/index.ts new file mode 100644 index 000000000..f63e8a6d2 --- /dev/null +++ b/packages/client/src/features/debug/index.ts @@ -0,0 +1,19 @@ +/******************************************************************************** + * Copyright (c) 2024 Axon Ivy AG and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ +export * from './debug-bounds-decorator'; +export * from './debug-manager'; +export * from './debug-model'; +export * from './debug-module'; diff --git a/packages/client/src/features/grid/grid-manager.ts b/packages/client/src/features/grid/grid-manager.ts new file mode 100644 index 000000000..29f2e9eb3 --- /dev/null +++ b/packages/client/src/features/grid/grid-manager.ts @@ -0,0 +1,60 @@ +/******************************************************************************** + * Copyright (c) 2024 Axon Ivy AG and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { IActionHandler, PropertiesOfType, TYPES } from '@eclipse-glsp/sprotty'; +import { inject, injectable, postConstruct } from 'inversify'; +import { FeedbackEmitter, IFeedbackActionDispatcher } from '../../base'; +import { Grid } from './grid'; +import { ShowGridAction } from './grid-model'; + +export type GridStyle = Record & Partial>; + +@injectable() +export class GridManager implements IActionHandler { + protected _gridVisible: boolean = false; + protected gridFeedback: FeedbackEmitter; + + @inject(TYPES.IFeedbackActionDispatcher) + protected feedbackDispatcher: IFeedbackActionDispatcher; + + @inject(TYPES.Grid) + public readonly grid: Grid; + + get isGridVisible(): boolean { + return this._gridVisible; + } + + @postConstruct() + protected init(): void { + this.gridFeedback = this.feedbackDispatcher.createEmitter(); + } + + handle(action: ShowGridAction): void { + this._gridVisible = action.show; + } + + setGridVisible(visible: boolean): void { + if (!visible) { + this.gridFeedback.dispose(); + } else { + this.gridFeedback.add(ShowGridAction.create({ show: true }), ShowGridAction.create({ show: false })).submit(); + } + } + + toggleGridVisible(): void { + this.setGridVisible(!this._gridVisible); + } +} diff --git a/packages/client/src/features/grid/grid-model.ts b/packages/client/src/features/grid/grid-model.ts new file mode 100644 index 000000000..8be2ff03f --- /dev/null +++ b/packages/client/src/features/grid/grid-model.ts @@ -0,0 +1,62 @@ +/******************************************************************************** + * Copyright (c) 2024 Axon Ivy AG and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ +import { Action, CommandExecutionContext, CommandReturn, GModelRoot, TYPES, hasBooleanProp } from '@eclipse-glsp/sprotty'; +import { inject, injectable } from 'inversify'; +import { FeedbackCommand } from '../../base'; +import { addCssClasses, removeCssClasses } from '../../utils'; + +export interface ShowGridAction extends Action { + kind: typeof ShowGridCommand.KIND; + show: boolean; +} + +export namespace ShowGridAction { + export const KIND = 'showGrid'; + export const CSS_ROOT_CLASS = 'grid-background'; + + export function is(object: any): object is ShowGridAction { + return Action.hasKind(object, KIND) && hasBooleanProp(object, 'show'); + } + + export function create(options: { show: boolean }): ShowGridAction { + return { + kind: ShowGridCommand.KIND, + ...options + }; + } +} + +@injectable() +export class ShowGridCommand extends FeedbackCommand { + static readonly KIND = ShowGridAction.KIND; + + constructor(@inject(TYPES.Action) protected readonly action: ShowGridAction) { + super(); + } + + execute(context: CommandExecutionContext): CommandReturn { + return this.setGrid(context.root, this.action.show); + } + + protected setGrid(root: GModelRoot, show: boolean): CommandReturn { + if (show) { + addCssClasses(root, [ShowGridAction.CSS_ROOT_CLASS]); + } else { + removeCssClasses(root, [ShowGridAction.CSS_ROOT_CLASS]); + } + return root; + } +} diff --git a/packages/client/src/features/grid/grid-module.ts b/packages/client/src/features/grid/grid-module.ts new file mode 100644 index 000000000..44966bb3e --- /dev/null +++ b/packages/client/src/features/grid/grid-module.ts @@ -0,0 +1,34 @@ +/******************************************************************************** + * Copyright (c) 2023 Axon Ivy AG and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { FeatureModule, TYPES, bindAsService, configureActionHandler, configureCommand } from '@eclipse-glsp/sprotty'; +import '../../../css/grid.css'; +import { GridManager } from './grid-manager'; +import { ShowGridAction, ShowGridCommand } from './grid-model'; +import { GridSnapper } from './grid-snapper'; + +export const gridModule = new FeatureModule((bind, unbind, isBound, rebind) => { + const context = { bind, unbind, isBound, rebind }; + + bind(TYPES.Grid).toConstantValue({ x: 10, y: 10 }); + + configureCommand(context, ShowGridCommand); + + bindAsService(bind, TYPES.IGridManager, GridManager); + configureActionHandler(context, ShowGridAction.KIND, GridManager); + + bind(TYPES.ISnapper).to(GridSnapper); +}); diff --git a/packages/client/src/features/grid/grid-snapper.spec.ts b/packages/client/src/features/grid/grid-snapper.spec.ts new file mode 100644 index 000000000..6e99eae2d --- /dev/null +++ b/packages/client/src/features/grid/grid-snapper.spec.ts @@ -0,0 +1,30 @@ +/******************************************************************************** + * Copyright (c) 2022-2023 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { GModelElement } from '@eclipse-glsp/sprotty'; +import { expect } from 'chai'; +import { GridSnapper } from './grid-snapper'; + +describe('GridSnapper', () => { + it('snap', () => { + const element = new GModelElement(); + const snapper = new GridSnapper(); + expect(snapper.snap({ x: 0, y: 0 }, element)).to.be.deep.equals({ x: 0, y: 0 }); + expect(snapper.snap({ x: 4, y: 5 }, element)).to.be.deep.equals({ x: 0, y: 10 }); + expect(snapper.snap({ x: 8, y: 11 }, element)).to.be.deep.equals({ x: 10, y: 10 }); + expect(snapper.snap({ x: -7, y: -4 }, element)).to.be.deep.equals({ x: -10, y: -0 }); + }); +}); diff --git a/packages/client/src/features/grid/grid-snapper.ts b/packages/client/src/features/grid/grid-snapper.ts new file mode 100644 index 000000000..210edf10a --- /dev/null +++ b/packages/client/src/features/grid/grid-snapper.ts @@ -0,0 +1,59 @@ +/******************************************************************************** + * Copyright (c) 2024 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { CenterGridSnapper, GModelElement, ISnapper, Point, TYPES } from '@eclipse-glsp/sprotty'; +import { inject, injectable, optional } from 'inversify'; +import { Grid } from './grid'; + +/** + * A {@link ISnapper} implementation that snaps all elements onto a fixed gride size. + * The default grid size is 10x10 pixel. + * To configure a custom grid size bind the `TYPES.ISnapper` service identifier + * to constant value, e.g: + * + * ```ts + * bind(TYPES.ISnapper).toConstantValue(new GridSnapper({ x: 25, y: 25 })); + * ``` + * + * or use the `Grid` to define the grid size more generically: + * ```ts + * bind(TYPES.Grid).toConstantValue({ x: 25, y: 25 }); + * bind(TYPES.ISnapper).to(GridSnapper); + * ``` + */ +@injectable() +export class GridSnapper implements ISnapper { + constructor(@optional() @inject(TYPES.Grid) public readonly grid: Grid = { x: 10, y: 10 }) {} + + snap(position: Point, element: GModelElement): Point { + return Point.snapToGrid(position, this.grid); + } +} + +@injectable() +export class GLSPCenterGridSnapper extends CenterGridSnapper { + constructor(@optional() @inject(TYPES.Grid) public readonly grid: Grid = { x: 10, y: 10 }) { + super(); + } + + override get gridX(): number { + return this.grid.x; + } + + override get gridY(): number { + return this.grid.y; + } +} diff --git a/packages/client/src/features/grid/grid.ts b/packages/client/src/features/grid/grid.ts new file mode 100644 index 000000000..2ef94be76 --- /dev/null +++ b/packages/client/src/features/grid/grid.ts @@ -0,0 +1,18 @@ +/******************************************************************************** + * Copyright (c) 2024 Axon Ivy AG and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ +import { Point } from '@eclipse-glsp/sprotty'; + +export type Grid = Point; diff --git a/packages/client/src/features/grid/index.ts b/packages/client/src/features/grid/index.ts new file mode 100644 index 000000000..16699e0c9 --- /dev/null +++ b/packages/client/src/features/grid/index.ts @@ -0,0 +1,20 @@ +/******************************************************************************** + * Copyright (c) 2024 Axon Ivy AG and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ +export * from './grid'; +export * from './grid-manager'; +export * from './grid-model'; +export * from './grid-module'; +export * from './grid-snapper'; diff --git a/packages/client/src/features/helper-lines/helper-line-manager-default.ts b/packages/client/src/features/helper-lines/helper-line-manager-default.ts index d503d22e4..a358c7c7b 100644 --- a/packages/client/src/features/helper-lines/helper-line-manager-default.ts +++ b/packages/client/src/features/helper-lines/helper-line-manager-default.ts @@ -19,6 +19,7 @@ import { FeedbackEmitter } from '../../base'; import { IFeedbackActionDispatcher } from '../../base/feedback/feedback-action-dispatcher'; import { ISelectionListener, SelectionService } from '../../base/selection-service'; import { SetBoundsFeedbackAction } from '../bounds/set-bounds-feedback-command'; +import { Grid } from '../grid'; import { MoveFinishedEventAction, MoveInitializedEventAction } from '../tools/change-bounds/change-bounds-tool-feedback'; import { AlignmentElementFilter, @@ -47,7 +48,7 @@ export interface IHelperLineOptions { viewportLines?: ViewportLineType[]; /** * The minimum difference between two coordinates - * Defaults to 1. + * Defaults to 1 or zero (perfect match) if the optional grid module is loaded. */ alignmentEpsilon?: number; /** @@ -58,6 +59,7 @@ export interface IHelperLineOptions { /** * The minimum move delta that is necessary for an element to break through a helper line. * Defaults to { x: 1, y: 1 } whereas the x represents the horizontal distance and y represents the vertical distance. + * If the optional grid module is loaded, defaults to twice the grid size, i.e., two grid moves to break through a helper line. */ minimumMoveDelta?: Point; @@ -84,6 +86,7 @@ export class HelperLineManager implements IActionHandler, ISelectionListener, IH @inject(TYPES.IFeedbackActionDispatcher) protected feedbackDispatcher: IFeedbackActionDispatcher; @inject(SelectionService) protected selectionService: SelectionService; @optional() @inject(TYPES.IHelperLineOptions) protected userOptions?: IHelperLineOptions; + @optional() @inject(TYPES.Grid) protected grid?: Grid; protected options: Required; protected feedback: FeedbackEmitter; @@ -91,7 +94,12 @@ export class HelperLineManager implements IActionHandler, ISelectionListener, IH @postConstruct() protected init(): void { this.feedback = this.feedbackDispatcher.createEmitter(); - this.options = { ...DEFAULT_HELPER_LINE_OPTIONS, ...this.userOptions }; + const dynamicOptions: IHelperLineOptions = {}; + if (this.grid) { + dynamicOptions.alignmentEpsilon = 0; + dynamicOptions.minimumMoveDelta = Point.multiplyScalar(this.grid, 2); + } + this.options = { ...DEFAULT_HELPER_LINE_OPTIONS, ...dynamicOptions, ...this.userOptions }; this.selectionService.onSelectionChanged(change => this.selectionChanged(change.root, change.selectedElements, change.deselectedElements) ); diff --git a/packages/client/src/features/index.ts b/packages/client/src/features/index.ts index 2afb59590..8f8fad345 100644 --- a/packages/client/src/features/index.ts +++ b/packages/client/src/features/index.ts @@ -19,9 +19,11 @@ export * from './change-bounds'; export * from './command-palette'; export * from './context-menu'; export * from './copy-paste'; +export * from './debug'; export * from './decoration'; export * from './element-template'; export * from './export'; +export * from './grid'; export * from './helper-lines'; export * from './hints'; export * from './hover'; diff --git a/packages/client/src/features/tool-palette/tool-palette.ts b/packages/client/src/features/tool-palette/tool-palette.ts index e2ccb21a5..968d53c89 100644 --- a/packages/client/src/features/tool-palette/tool-palette.ts +++ b/packages/client/src/features/tool-palette/tool-palette.ts @@ -26,23 +26,27 @@ import { SetContextActions, SetModelAction, SetUIExtensionVisibilityAction, + TYPES, TriggerNodeCreationAction, UpdateModelAction, codiconCSSClasses, matchesKeystroke } from '@eclipse-glsp/sprotty'; -import { inject, injectable, postConstruct } from 'inversify'; +import { inject, injectable, optional, postConstruct } from 'inversify'; import { GLSPActionDispatcher } from '../../base/action-dispatcher'; import { EditorContextService, IEditModeListener } from '../../base/editor-context-service'; import { FocusTracker } from '../../base/focus/focus-tracker'; import { IDiagramStartup } from '../../base/model/diagram-loader'; import { EnableDefaultToolsAction, EnableToolsAction } from '../../base/tool-manager/tool'; +import { DebugManager } from '../debug'; +import { GridManager } from '../grid'; import { MouseDeleteTool } from '../tools/deletion/delete-tool'; import { MarqueeMouseTool } from '../tools/marquee-selection/marquee-mouse-tool'; +import { OriginViewportAction } from '../viewport'; const CLICKED_CSS_CLASS = 'clicked'; const SEARCH_ICON_ID = 'search'; -const PALETTE_ICON_ID = 'symbol-color'; +const PALETTE_ICON_ID = 'tools'; const CHEVRON_DOWN_ICON_ID = 'chevron-right'; const PALETTE_HEIGHT = '500px'; @@ -74,6 +78,14 @@ export class ToolPalette extends AbstractUIExtension implements IActionHandler, @inject(FocusTracker) protected focusTracker: FocusTracker; + @inject(TYPES.IGridManager) + @optional() + protected gridManager?: GridManager; + + @inject(TYPES.IDebugManager) + @optional() + protected debugManager?: DebugManager; + protected paletteItems: PaletteItem[]; protected paletteItemsCopy: PaletteItem[] = []; protected dynamic = false; @@ -202,6 +214,19 @@ export class ToolPalette extends AbstractUIExtension implements IActionHandler, const validateActionButton = this.createValidateButton(); headerTools.appendChild(validateActionButton); + const resetViewportButton = this.createResetViewportButton(); + headerTools.appendChild(resetViewportButton); + + if (this.gridManager) { + const toggleGridButton = this.createToggleGridButton(); + headerTools.appendChild(toggleGridButton); + } + + if (this.debugManager) { + const toggleDebugButton = this.createToggleDebugButton(); + headerTools.appendChild(toggleDebugButton); + } + // Create button for Search const searchIcon = this.createSearchButton(); headerTools.appendChild(searchIcon); @@ -250,6 +275,58 @@ export class ToolPalette extends AbstractUIExtension implements IActionHandler, return validateActionButton; } + protected createResetViewportButton(): HTMLElement { + const resetViewportButton = createIcon('screen-normal'); + resetViewportButton.title = 'Reset Viewport'; + resetViewportButton.onclick = _event => { + this.actionDispatcher.dispatch(OriginViewportAction.create()); + resetViewportButton.focus(); + }; + resetViewportButton.ariaLabel = resetViewportButton.title; + resetViewportButton.tabIndex = 1; + return resetViewportButton; + } + + protected createToggleGridButton(): HTMLElement { + const toggleGridButton = createIcon('symbol-numeric'); + toggleGridButton.title = 'Toggle Grid'; + toggleGridButton.onclick = () => { + if (this.gridManager?.isGridVisible) { + toggleGridButton.classList.remove(CLICKED_CSS_CLASS); + this.gridManager?.setGridVisible(false); + } else { + toggleGridButton.classList.add(CLICKED_CSS_CLASS); + this.gridManager?.setGridVisible(true); + } + }; + if (this.gridManager?.isGridVisible) { + toggleGridButton.classList.add(CLICKED_CSS_CLASS); + } + toggleGridButton.ariaLabel = toggleGridButton.title; + toggleGridButton.tabIndex = 1; + return toggleGridButton; + } + + protected createToggleDebugButton(): HTMLElement { + const toggleDebugButton = createIcon('debug'); + toggleDebugButton.title = 'Debug Mode'; + toggleDebugButton.onclick = () => { + if (this.debugManager?.isDebugEnabled) { + toggleDebugButton.classList.remove(CLICKED_CSS_CLASS); + this.debugManager?.setDebugEnabled(false); + } else { + toggleDebugButton.classList.add(CLICKED_CSS_CLASS); + this.debugManager?.setDebugEnabled(true); + } + }; + if (this.debugManager?.isDebugEnabled) { + toggleDebugButton.classList.add(CLICKED_CSS_CLASS); + } + toggleDebugButton.ariaLabel = toggleDebugButton.title; + toggleDebugButton.tabIndex = 1; + return toggleDebugButton; + } + protected createSearchButton(): HTMLElement { const searchIcon = createIcon(SEARCH_ICON_ID); searchIcon.onclick = _ev => { @@ -275,7 +352,7 @@ export class ToolPalette extends AbstractUIExtension implements IActionHandler, searchField.classList.add('search-input'); searchField.id = this.containerElement.id + '_search_field'; searchField.type = 'text'; - searchField.placeholder = ' Search...'; + searchField.placeholder = 'Search...'; searchField.style.display = 'none'; searchField.onkeyup = () => this.requestFilterUpdate(this.searchField.value); searchField.onkeydown = ev => this.clearOnEscape(ev); diff --git a/packages/client/src/features/viewport/index.ts b/packages/client/src/features/viewport/index.ts index 852ad1912..0ccb5efcb 100644 --- a/packages/client/src/features/viewport/index.ts +++ b/packages/client/src/features/viewport/index.ts @@ -14,6 +14,7 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ export * from './glsp-scroll-mouse-listener'; +export * from './origin-viewport'; export * from './reposition'; export * from './viewport-handler'; export * from './viewport-modules'; diff --git a/packages/client/src/features/viewport/origin-viewport.ts b/packages/client/src/features/viewport/origin-viewport.ts new file mode 100644 index 000000000..7c621a415 --- /dev/null +++ b/packages/client/src/features/viewport/origin-viewport.ts @@ -0,0 +1,68 @@ +/******************************************************************************** + * Copyright (c) 2024 Axon Ivy AG and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { Action, Bounds, BoundsAwareViewportCommand, GModelRoot, TYPES, Viewport, isViewport, limitViewport } from '@eclipse-glsp/sprotty'; +import { inject, injectable } from 'inversify'; + +export interface OriginViewportAction extends Action { + kind: typeof OriginViewportAction.KIND; + animate: boolean; +} + +export namespace OriginViewportAction { + export const KIND = 'originViewport'; + + export function is(object: any): object is OriginViewportAction { + return Action.hasKind(object, KIND); + } + + export function create(options: { animate?: boolean } = {}): OriginViewportAction { + return { + kind: KIND, + animate: true, + ...options + }; + } +} + +@injectable() +export class OriginViewportCommand extends BoundsAwareViewportCommand { + static readonly KIND = OriginViewportAction.KIND; + + constructor(@inject(TYPES.Action) protected action: OriginViewportAction) { + super(action.animate); + } + + getElementIds(): string[] { + return []; + } + + protected override initialize(model: GModelRoot): void { + if (!isViewport(model)) { + return; + } + this.oldViewport = { scroll: model.scroll, zoom: model.zoom }; + const newViewport = this.getNewViewport(Bounds.EMPTY, model); + if (newViewport) { + const { zoomLimits, horizontalScrollLimits, verticalScrollLimits } = this.viewerOptions; + this.newViewport = limitViewport(newViewport, model.canvasBounds, horizontalScrollLimits, verticalScrollLimits, zoomLimits); + } + } + + getNewViewport(_bounds: Bounds, _model: GModelRoot): Viewport | undefined { + return { zoom: 1, scroll: { x: 0, y: 0 } }; + } +} diff --git a/packages/client/src/features/viewport/viewport-modules.ts b/packages/client/src/features/viewport/viewport-modules.ts index 69470780b..e2ea67338 100644 --- a/packages/client/src/features/viewport/viewport-modules.ts +++ b/packages/client/src/features/viewport/viewport-modules.ts @@ -27,10 +27,11 @@ import { ZoomMouseListener } from '@eclipse-glsp/sprotty'; import { EnableDefaultToolsAction, EnableToolsAction } from '../../base/tool-manager/tool'; +import { FocusDomAction } from '../accessibility/actions'; import { GLSPScrollMouseListener } from './glsp-scroll-mouse-listener'; -import { RestoreViewportHandler } from './viewport-handler'; +import { OriginViewportCommand } from './origin-viewport'; import { RepositionCommand } from './reposition'; -import { FocusDomAction } from '../accessibility/actions'; +import { RestoreViewportHandler } from './viewport-handler'; export const viewportModule = new FeatureModule((bind, _unbind, isBound) => { const context = { bind, isBound }; @@ -39,6 +40,7 @@ export const viewportModule = new FeatureModule((bind, _unbind, isBound) => { configureCommand(context, GetViewportCommand); configureCommand(context, SetViewportCommand); configureCommand(context, RepositionCommand); + configureCommand(context, OriginViewportCommand); bindAsService(context, TYPES.MouseListener, ZoomMouseListener); bindAsService(context, TYPES.MouseListener, GLSPScrollMouseListener); diff --git a/packages/client/src/views/base-view-module.ts b/packages/client/src/views/base-view-module.ts index db89de6af..3e198cf2b 100644 --- a/packages/client/src/views/base-view-module.ts +++ b/packages/client/src/views/base-view-module.ts @@ -27,9 +27,9 @@ import { GCompartment, GCompartmentView, GForeignObjectElement, - GGraphView, GHtmlRoot, GLabel, + GLabelView, GNode, GPort, GPreRenderedElement, @@ -41,17 +41,17 @@ import { PreRenderedView, RectangularNode, RectangularNodeView, - GLabelView, SvgViewportView, configureModelElement, moveFeature, selectFeature } from '@eclipse-glsp/sprotty'; import { GIssueMarker } from '../features/validation/issue-marker'; +import { GEdge, GGraph } from '../model'; import { GEdgeView } from './gedge-view'; +import { GGraphView } from './ggraph-view'; import { GIssueMarkerView } from './issue-marker-view'; import { RoundedCornerNodeView } from './rounded-corner-view'; -import { GEdge, GGraph } from '../model'; export const baseViewModule = new FeatureModule((bind, unbind, isBound, rebind) => { const context = { bind, unbind, isBound, rebind }; diff --git a/packages/client/src/views/ggraph-view.tsx b/packages/client/src/views/ggraph-view.tsx new file mode 100644 index 000000000..7c2c9f188 --- /dev/null +++ b/packages/client/src/views/ggraph-view.tsx @@ -0,0 +1,56 @@ +/******************************************************************************** + * Copyright (c) 2024 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ +import { Bounds, Dimension, Point, RenderingContext, SGraphImpl, SGraphView, TYPES, Writable } from '@eclipse-glsp/sprotty'; +import { inject, injectable, optional } from 'inversify'; +import { VNode } from 'snabbdom'; +import { GridManager, GridStyle } from '../features'; + +@injectable() +export class GGraphView extends SGraphView { + @inject(TYPES.IGridManager) @optional() protected gridManager?: GridManager; + + override render(model: Readonly, context: RenderingContext): VNode { + const graph = super.render(model, context); + if (graph.data) { + graph.data.style = { ...graph.data.style, ...this.getGridStyle(model, context) }; + } + return graph; + } + + protected getGridStyle(model: Readonly, context: RenderingContext): GridStyle { + if (!this.gridManager?.isGridVisible) { + return {}; + } + const bounds = this.getBackgroundBounds(model, context, this.gridManager); + return { + backgroundPosition: `${bounds.x}px ${bounds.y}px`, + backgroundSize: `${bounds.width}px ${bounds.height}px`, + // we do not set the background image directly in the style object, because we want to toggle it on and off via CSS + '--grid-background-image': this.getBackgroundImage(model, context, this.gridManager) + }; + } + + protected getBackgroundBounds(viewport: Readonly, context: RenderingContext, gridManager: GridManager): Writable { + const position = Point.multiplyScalar(Point.subtract(gridManager.grid, viewport.scroll), viewport.zoom); + const size = Dimension.fromPoint(Point.multiplyScalar(gridManager.grid, viewport.zoom)); + return { ...position, ...size }; + } + + protected getBackgroundImage(model: Readonly, context: RenderingContext, gridManager: GridManager): string { + // eslint-disable-next-line max-len + return `url('data:image/svg+xml;utf8, ')`; + } +} diff --git a/packages/client/src/views/glsp-projection-view.tsx b/packages/client/src/views/glsp-projection-view.tsx index 2837cb53f..69ee9ceea 100644 --- a/packages/client/src/views/glsp-projection-view.tsx +++ b/packages/client/src/views/glsp-projection-view.tsx @@ -16,19 +16,25 @@ import { Bounds, + Dimension, EdgeRouterRegistry, GViewportRootElement, IViewArgs, + Point, ProjectedViewportView, ProjectionParams, RenderingContext, + SGraphImpl, + TYPES, ViewProjection, + Writable, html, setAttr, setClass } from '@eclipse-glsp/sprotty'; -import { inject, injectable } from 'inversify'; +import { inject, injectable, optional } from 'inversify'; import { VNode, VNodeStyle, h } from 'snabbdom'; +import { GridManager, GridStyle } from '../features'; // eslint-disable-next-line @typescript-eslint/no-unused-vars const JSX = { createElement: html }; @@ -39,15 +45,12 @@ const JSX = { createElement: html }; @injectable() export class GLSPProjectionView extends ProjectedViewportView { @inject(EdgeRouterRegistry) edgeRouterRegistry: EdgeRouterRegistry; + @inject(TYPES.IGridManager) @optional() protected gridManager?: GridManager; override render(model: Readonly, context: RenderingContext, args?: IViewArgs): VNode { - const svgElem = this.renderSvg(model, context, args); - if (svgElem.data) { - svgElem.data!.class = { 'sprotty-graph': true }; - } const rootNode: VNode = (
- {svgElem} + {this.renderSvg(model, context, args)} {this.renderProjections(model, context, args)}
); @@ -63,12 +66,36 @@ export class GLSPProjectionView extends ProjectedViewportView { const ns = 'http://www.w3.org/2000/svg'; const svg = h( 'svg', - { ns, style: { height: '100%' } }, + { ns, style: { height: '100%', ...this.getGridStyle(model, context) }, class: { 'sprotty-graph': true } }, h('g', { ns, attrs: { transform } }, context.renderChildren(model, { edgeRouting })) ); return svg; } + protected getGridStyle(model: Readonly, context: RenderingContext): GridStyle { + if (!this.gridManager?.isGridVisible) { + return {}; + } + const bounds = this.getBackgroundBounds(model, context, this.gridManager); + return { + backgroundPosition: `${bounds.x}px ${bounds.y}px`, + backgroundSize: `${bounds.width}px ${bounds.height}px`, + // we do not set the background image directly in the style object, because we want to toggle it on and off via CSS + '--grid-background-image': this.getBackgroundImage(model, context, this.gridManager) + }; + } + + protected getBackgroundBounds(viewport: Readonly, context: RenderingContext, gridManager: GridManager): Writable { + const position = Point.multiplyScalar(Point.subtract(gridManager.grid, viewport.scroll), viewport.zoom); + const size = Dimension.fromPoint(Point.multiplyScalar(gridManager.grid, viewport.zoom)); + return { ...position, ...size }; + } + + protected getBackgroundImage(model: Readonly, context: RenderingContext, gridManager: GridManager): string { + // eslint-disable-next-line max-len + return `url('data:image/svg+xml;utf8, ')`; + } + protected override renderProjectionBar( projections: ViewProjection[], model: Readonly, diff --git a/packages/client/src/views/index.ts b/packages/client/src/views/index.ts index 97690b3af..29e3d7598 100644 --- a/packages/client/src/views/index.ts +++ b/packages/client/src/views/index.ts @@ -16,6 +16,7 @@ export * from './base-view-module'; export * from './compartments'; export * from './gedge-view'; +export * from './ggraph-view'; export * from './glsp-projection-view'; export * from './issue-marker-view'; export * from './rounded-corner'; diff --git a/packages/glsp-sprotty/src/re-exports.ts b/packages/glsp-sprotty/src/re-exports.ts index e5d0a5c9d..5978e58e1 100644 --- a/packages/glsp-sprotty/src/re-exports.ts +++ b/packages/glsp-sprotty/src/re-exports.ts @@ -266,12 +266,12 @@ export { SBezierControlHandleView as GBezierControlHandleView, SBezierCreateHandleView as GBezierCreateHandleView, SCompartmentView as GCompartmentView, - SGraphView as GGraphView, SLabelView as GLabelView, SRoutingHandleView as GRoutingHandleView, JumpingPolylineEdgeView, PolylineEdgeView, - PolylineEdgeViewWithGapsOnIntersections + PolylineEdgeViewWithGapsOnIntersections, + SGraphView } from 'sprotty/lib/graph/views'; // ------------------ Library ------------------ diff --git a/packages/glsp-sprotty/src/types.ts b/packages/glsp-sprotty/src/types.ts index bb796d534..1b018bdd0 100644 --- a/packages/glsp-sprotty/src/types.ts +++ b/packages/glsp-sprotty/src/types.ts @@ -47,5 +47,8 @@ export const TYPES = { ILocalElementNavigator: Symbol('ILocalElementNavigator'), IDiagramOptions: Symbol('IDiagramOptions'), IDiagramStartup: Symbol('IDiagramStartup'), - IToolManager: Symbol('IToolManager') + IToolManager: Symbol('IToolManager'), + IDebugManager: Symbol('IDebugManager'), + Grid: Symbol('Grid'), + IGridManager: Symbol('IGridManager') }; diff --git a/packages/protocol/src/sprotty-geometry-dimension.spec.ts b/packages/protocol/src/sprotty-geometry-dimension.spec.ts index 02f0be669..1ed63d658 100644 --- a/packages/protocol/src/sprotty-geometry-dimension.spec.ts +++ b/packages/protocol/src/sprotty-geometry-dimension.spec.ts @@ -84,4 +84,12 @@ describe('Dimension', () => { expect(isEqual).to.be.false; }); }); + + describe('fromPoint', () => { + it('should create a new dimension from the given point', () => { + const point = { x: 10, y: 20 }; + const dimension = Dimension.fromPoint(point); + expect(dimension).to.deep.equal({ width: 10, height: 20 }); + }); + }); }); diff --git a/packages/protocol/src/sprotty-geometry-dimension.ts b/packages/protocol/src/sprotty-geometry-dimension.ts index e3a1ccd38..c9582c2e7 100644 --- a/packages/protocol/src/sprotty-geometry-dimension.ts +++ b/packages/protocol/src/sprotty-geometry-dimension.ts @@ -19,6 +19,11 @@ import { Dimension, Point } from 'sprotty-protocol/lib/utils/geometry'; declare module 'sprotty-protocol/lib/utils/geometry' { namespace Dimension { + /** + * The smallest valid dimension with width, and height set to 0. + */ + const ZERO: Dimension; + /** * Applies the given function to the `width` and `height` of the given dimensional object to create a new dimensional object. * @@ -75,9 +80,21 @@ declare module 'sprotty-protocol/lib/utils/geometry' { * @returns true if the dimensions are equal, false otherwise */ function equals(left: Dimension, right: Dimension): boolean; + + /** + * Creates a new dimension from the given point. The `width` and `height` of the new dimension are the `x` and `y` of the point. + * @param point the point + * @returns new dimension + */ + function fromPoint(point: Point): Dimension; } } +(Dimension as any).ZERO = Object.freeze({ + width: 0, + height: 0 +}); + Dimension.center = (d: Dimension): Point => ({ x: d.width * 0.5, y: d.height * 0.5 }); Dimension.add = (d: Dimension, a: Dimension): Dimension => ({ width: d.width + a.width, height: d.height + a.height }); Dimension.subtract = (d: Dimension, a: Dimension): Dimension => ({ width: d.width - a.width, height: d.height - a.height }); @@ -90,5 +107,6 @@ Dimension.map = (dimension: T, callbackfn: (value: number, height: callbackfn(dimension.height, 'height') }); Dimension.equals = (left: Dimension, right: Dimension): boolean => left.width === right.width && left.height === right.height; +Dimension.fromPoint = (point: Point): Dimension => ({ width: point.x, height: point.y }); export { Dimension }; diff --git a/packages/protocol/src/utils/type-util.ts b/packages/protocol/src/utils/type-util.ts index e979ae7fe..9d5d740be 100644 --- a/packages/protocol/src/utils/type-util.ts +++ b/packages/protocol/src/utils/type-util.ts @@ -49,6 +49,16 @@ export interface Constructor { // eslint-disable-next-line @typescript-eslint/ban-types export type Writable = { -readonly [P in keyof T]: Writable }; +/** + * Utility type to extract all key of type `V` from a given type `T`. + */ +export type KeysOfType = { [K in keyof T]: T[K] extends V ? K : never }[keyof T]; + +/** + * Utility type to extract all properties of type `V` from a given type `T`. + */ +export type PropertiesOfType = Pick>; + /** * Utility type to describe a value as might be provided as a promise. */