Skip to content

Commit

Permalink
Support rendering of ghost element when creating elements
Browse files Browse the repository at this point in the history
- Introduce new element template module
-- Adding new elements to the diagram based on templates
-- Removing templates again

- Support local bounds calculation for client-only added elements
-- Must trigger RequestBounds for hidden view calculation
-- Must not modify main view until ComputedBounds response is handled
-- Mark ResizeHandles as not to be considered for bounds calculation
-- Fix positioning of decorations for hidden view calculation

- Add ghost element extension to tool palette
-- Optional ghost element field in the trigger node creation action
-- Ghost element is added as feedback to the mouse cursor
-- If ghost element is dynamic, templates (palette items) are reloaded

- Bonus: Re-calculate local bounds for resize behavior

Fixes eclipse-glsp/glsp#1159
  • Loading branch information
martin-fleck-at committed Nov 12, 2023
1 parent f5bcc1c commit 86c4644
Show file tree
Hide file tree
Showing 27 changed files with 781 additions and 81 deletions.
24 changes: 24 additions & 0 deletions packages/client/css/ghost-element.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/********************************************************************************
* Copyright (c) 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
********************************************************************************/

.ghost-element {
/* we are a true ghost so we do not want to be used as a target for any mouse event */
pointer-events: none;
}

.ghost-element.hidden {
display: none;
}
10 changes: 10 additions & 0 deletions packages/client/src/base/args-feature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,13 @@ export function isArgsAware(element: GModelElement): element is GModelElement &
export function hasArgs(element?: GModelElement): element is GModelElement & Required<ArgsAware> {
return element !== undefined && isArgsAware(element) && element.args !== undefined;
}

export function ensureArgs(element?: GModelElement): element is GModelElement & Required<ArgsAware> {
if (element === undefined || !isArgsAware(element)) {
return false;
}
if (element.args === undefined) {
element.args = {};
}
return true;
}
5 changes: 4 additions & 1 deletion packages/client/src/base/feedback/css-feedback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@
*
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
********************************************************************************/
import { inject, injectable } from 'inversify';
import { Action, CommandExecutionContext, GModelElement, GModelRoot, TYPES, hasArrayProp } from '@eclipse-glsp/sprotty';
import { inject, injectable } from 'inversify';
import { addCssClasses, getElements, removeCssClasses } from '../../utils/gmodel-util';
import { FeedbackCommand } from './feedback-command';

Expand Down Expand Up @@ -69,6 +69,9 @@ export class ModifyCssFeedbackCommand extends FeedbackCommand {
}
}

export const CSS_GHOST_ELEMENT = 'ghost-element';
export const CSS_HIDDEN = 'hidden';

export enum CursorCSS {
DEFAULT = 'default-mode',
OVERLAP_FORBIDDEN = 'overlap-forbidden-mode',
Expand Down
2 changes: 2 additions & 0 deletions packages/client/src/default-modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { commandPaletteModule } from './features/command-palette/command-palette
import { contextMenuModule } from './features/context-menu/context-menu-module';
import { copyPasteModule } from './features/copy-paste/copy-paste-modules';
import { decorationModule } from './features/decoration/decoration-module';
import { elementTemplateModule } from './features/element-template/element-template-module';
import { exportModule } from './features/export/export-modules';
import { typeHintsModule } from './features/hints/type-hints-module';
import { hoverModule } from './features/hover/hover-module';
Expand Down Expand Up @@ -90,6 +91,7 @@ export const DEFAULT_MODULES = [
edgeCreationToolModule,
edgeEditToolModule,
deletionToolModule,
elementTemplateModule,
nodeCreationToolModule,
changeBoundsToolModule,
marqueeSelectionToolModule,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
********************************************************************************/
import '../../../../css/keyboard-tool-palette.css';

import { configureActionHandler, TYPES, bindAsService, BindingContext, FeatureModule } from '@eclipse-glsp/sprotty';
import { configureActionHandler, TYPES, bindAsService, BindingContext, FeatureModule, UpdateModelAction, SetModelAction } from '@eclipse-glsp/sprotty';

Check warning on line 18 in packages/client/src/features/accessibility/keyboard-tool-palette/keyboard-tool-palette-module.ts

View check run for this annotation

Jenkins - GLSP / ESLint

max-len

NORMAL: This line has a length of 151. Maximum allowed is 140. (max-len)
import { EnableToolPaletteAction } from '../../tool-palette/tool-palette';
import { KeyboardToolPalette } from './keyboard-tool-palette';
import { FocusDomAction } from '../actions';
Expand All @@ -32,4 +32,6 @@ export function configureKeyboardToolPaletteTool(context: BindingContext): void
configureActionHandler(context, EnableDefaultToolsAction.KIND, KeyboardToolPalette);
configureActionHandler(context, FocusDomAction.KIND, KeyboardToolPalette);
configureActionHandler(context, EnableToolPaletteAction.KIND, KeyboardToolPalette);
configureActionHandler(context, UpdateModelAction.KIND, KeyboardToolPalette);
configureActionHandler(context, SetModelAction.KIND, KeyboardToolPalette);
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,35 +13,35 @@
*
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
********************************************************************************/
import { injectable } from 'inversify';
import {
ICommand,
SetUIExtensionVisibilityAction,
Action,
ICommand,
PaletteItem,
RequestContextActions,
RequestMarkersAction,
SetContextActions,
SetUIExtensionVisibilityAction,
TriggerNodeCreationAction
} from '@eclipse-glsp/sprotty';
import { injectable } from 'inversify';
import { KeyCode, matchesKeystroke } from 'sprotty/lib/utils/keyboard';
import { MouseDeleteTool } from '../../tools/deletion/delete-tool';
import { MarqueeMouseTool } from '../../tools/marquee-selection/marquee-mouse-tool';
import { EnableDefaultToolsAction, EnableToolsAction } from '../../../base/tool-manager/tool';
import {
createIcon,
changeCodiconClass,
createToolGroup,
EnableToolPaletteAction,
ToolPalette,
changeCodiconClass,
compare,
EnableToolPaletteAction
createIcon,
createToolGroup
} from '../../tool-palette/tool-palette';
import { KeyboardNodeGridMetadata } from '../keyboard-grid/constants';
import { MouseDeleteTool } from '../../tools/deletion/delete-tool';
import { MarqueeMouseTool } from '../../tools/marquee-selection/marquee-mouse-tool';
import { FocusDomAction } from '../actions';
import { EdgeAutocompletePaletteMetadata } from '../edge-autocomplete/edge-autocomplete-palette';
import { EnableDefaultToolsAction, EnableToolsAction } from '../../../base/tool-manager/tool';
import { ShowToastMessageAction } from '../toast/toast-handler';
import { ElementNavigatorKeyListener } from '../element-navigation/diagram-navigation-tool';
import { KeyboardNodeGridMetadata } from '../keyboard-grid/constants';
import * as messages from '../toast/messages.json';
import { ShowToastMessageAction } from '../toast/toast-handler';

const SEARCH_ICON_ID = 'search';
const PALETTE_ICON_ID = 'symbol-color';
Expand Down Expand Up @@ -144,6 +144,8 @@ export class KeyboardToolPalette extends ToolPalette {
this.showShortcuts();
}
this.containerElement.focus();
} else {
super.handle(action);
}
}

Expand Down
6 changes: 6 additions & 0 deletions packages/client/src/features/bounds/bounds-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import { FreeFormLayouter } from './freeform-layout';
import { GLSPHiddenBoundsUpdater } from './glsp-hidden-bounds-updater';
import { HBoxLayouterExt } from './hbox-layout';
import { LayouterExt } from './layouter';
import { LocalComputedBoundsCommand } from './local-bounds';
import { SetBoundsFeebackCommand } from './set-bounds-feedback-command';
import { VBoxLayouterExt } from './vbox-layout';

export const boundsModule = new FeatureModule((bind, _unbind, isBound, _rebind) => {
Expand All @@ -38,6 +40,10 @@ export const boundsModule = new FeatureModule((bind, _unbind, isBound, _rebind)
configureCommand(context, RequestBoundsCommand);
bind(HiddenBoundsUpdater).toSelf().inSingletonScope();
bindAsService(context, TYPES.HiddenVNodePostprocessor, GLSPHiddenBoundsUpdater);

configureCommand(context, LocalComputedBoundsCommand);
configureCommand(context, SetBoundsFeebackCommand);

bind(TYPES.Layouter).to(LayouterExt).inSingletonScope();
bind(TYPES.LayoutRegistry).to(LayoutRegistry).inSingletonScope();

Expand Down
78 changes: 72 additions & 6 deletions packages/client/src/features/bounds/glsp-hidden-bounds-updater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,30 @@
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
********************************************************************************/

import { inject, injectable, optional } from 'inversify';
import { VNode } from 'snabbdom';
import {
ATTR_BBOX_ELEMENT,
Action,
Bounds,
BoundsAware,
ComputedBoundsAction,
Deferred,
Disposable,
DisposableCollection,
EdgeRouterRegistry,
ElementAndRoutingPoints,
GModelElement,
GRoutableElement,
HiddenBoundsUpdater,
IActionDispatcher,
RequestAction,
ResponseAction,
GModelElement,
GRoutableElement
isSVGGraphicsElement
} from '@eclipse-glsp/sprotty';
import { inject, injectable, optional } from 'inversify';
import { VNode } from 'snabbdom';
import { GArgument } from '../../utils/argument-utils';
import { calcElementAndRoute, isRoutable } from '../../utils/gmodel-util';
import { LocalComputedBoundsAction, LocalRequestBoundsAction } from './local-bounds';

/**
* Grabs the bounds from hidden SVG DOM elements, applies layouts, collects routes and fires {@link ComputedBoundsAction}s.
Expand All @@ -56,7 +64,7 @@ export class GLSPHiddenBoundsUpdater extends HiddenBoundsUpdater {
const actions = this.captureActions(() => super.postUpdate(cause));
actions
.filter(action => ComputedBoundsAction.is(action))
.forEach(action => this.actionDispatcher.dispatch(this.enhanceAction(action as ComputedBoundsAction)));
.forEach(action => this.actionDispatcher.dispatch(this.enhanceAction(action as ComputedBoundsAction, cause)));
this.element2route = [];
}

Expand All @@ -72,10 +80,59 @@ export class GLSPHiddenBoundsUpdater extends HiddenBoundsUpdater {
}
}

protected enhanceAction(action: ComputedBoundsAction): ComputedBoundsAction {
protected enhanceAction(action: ComputedBoundsAction, cause?: Action): ComputedBoundsAction {
if (LocalRequestBoundsAction.is(cause)) {
LocalComputedBoundsAction.mark(action);
}
action.routes = this.element2route.length === 0 ? undefined : this.element2route;
return action;
}

protected override getBounds(elm: Node, element: GModelElement & BoundsAware): Bounds {
if (!isSVGGraphicsElement(elm)) {
this.logger.error(this, 'Not an SVG element:', elm);
return Bounds.EMPTY;
}
if (elm.tagName === 'g') {
for (const child of Array.from(elm.children)) {
// eslint-disable-next-line no-null/no-null
if (child.getAttribute(ATTR_BBOX_ELEMENT) !== null) {
return this.getBounds(child, element);
}
}
}
const bounds = this.getBBounds(elm, element);
return {
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height
};
}

protected getBBounds(elm: SVGGraphicsElement, element: GModelElement & BoundsAware): DOMRect {
// CUSTOMIZATION: Hide certain elements during bbox calculation
if (GArgument.getBoolean(element, ARG_HAS_HIDDEN_BBOX_ELEMENT)) {
const restore = this.ignoreHiddenBBoxElements(elm);
const bounds = elm.getBBox();
restore.dispose();
return bounds;
}
// END CUSTOMIZATION
return elm.getBBox();
}

protected ignoreHiddenBBoxElements(elm: Element): Disposable {
const revert = new DisposableCollection();
// eslint-disable-next-line no-null/no-null
if (isSVGGraphicsElement(elm) && elm.getAttribute(ATTR_HIDDEN_BBOX_ELEMENT) !== null) {
const prevStyle = elm.style.display;
elm.style.display = 'none';
revert.push(() => (elm.style.display = prevStyle));
}
revert.push(...Array.from(elm.children).map(child => this.ignoreHiddenBBoxElements(child)));
return revert;
}
}

class CapturingActionDispatcher implements IActionDispatcher {
Expand All @@ -94,3 +151,12 @@ class CapturingActionDispatcher implements IActionDispatcher {
return new Deferred<Res>().promise;
}
}

/** If the this attribute is present on an element, it will be ignored during the bounding box calculation of it's parent. */
export const ATTR_HIDDEN_BBOX_ELEMENT = 'hiddenBboxElement';

/**
* If this argument is set to true this elements requires special handling during bounding box calculation as it has children
* whose size should not be considered.
*/
export const ARG_HAS_HIDDEN_BBOX_ELEMENT = 'hasHiddenBboxElement';
91 changes: 91 additions & 0 deletions packages/client/src/features/bounds/local-bounds.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/********************************************************************************
* Copyright (c) 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 {
Action,
ActionDispatcher,
Command,
CommandExecutionContext,
CommandResult,
CommandReturn,
ComputedBoundsAction,
ComputedBoundsApplicator,
GModelRoot,
GModelRootSchema,
RequestBoundsAction,
TYPES
} from '@eclipse-glsp/sprotty';
import { inject, injectable } from 'inversify';
import { ServerAction } from '../../base/model/glsp-model-source';

export namespace LocalRequestBoundsAction {
export function is(object: unknown): object is RequestBoundsAction {
return RequestBoundsAction.is(object) && !ServerAction.is(object);
}

export function fromCommand(model: GModelRoot, actionDispatcher: ActionDispatcher, cause?: Action): CommandResult {
// do not modify the main model (modelChanged = false) but request local bounds calculation on hidden model
actionDispatcher.dispatch(RequestBoundsAction.create(model as unknown as GModelRootSchema));
return {
model,
modelChanged: false,
cause
};
}
}

export namespace LocalComputedBoundsAction {
export function is(object: unknown): object is RequestBoundsAction {
return ComputedBoundsAction.is(object) && ServerAction.is(object);
}

export function mark(action: ComputedBoundsAction): ComputedBoundsAction {
// mimic: we mark the computed bounds action as coming from the server so it is not sent to the server and handled locally
ServerAction.mark(action);
return action;
}
}

@injectable()
export class LocalComputedBoundsCommand extends Command {
static readonly KIND: string = ComputedBoundsAction.KIND;

@inject(ComputedBoundsApplicator) protected readonly computedBoundsApplicator: ComputedBoundsApplicator;

constructor(@inject(TYPES.Action) readonly action: ComputedBoundsAction) {
super();
}

override execute(context: CommandExecutionContext): GModelRoot | CommandResult {
if (LocalComputedBoundsAction.is(this.action)) {
// apply computed bounds from the hidden model and return updated model to render new main model
this.computedBoundsApplicator.apply(context.root as unknown as GModelRootSchema, this.action);
return context.root;
}
// computed bounds action from server -> we do not care and do not trigger any update of the main model
return {
model: context.root,
modelChanged: false
};
}

override undo(context: CommandExecutionContext): CommandReturn {
return context.root;
}

override redo(context: CommandExecutionContext): CommandReturn {
return context.root;
}
}
Loading

0 comments on commit 86c4644

Please sign in to comment.