From f28e99b61f1230c891c663971b9a8284fc6e71c6 Mon Sep 17 00:00:00 2001 From: Philip Langer Date: Tue, 24 Mar 2020 09:25:17 +0100 Subject: [PATCH 1/5] Add support for foreignObject https://github.com/eclipse/sprotty/issues/169 --- examples/svg/src/di.config.ts | 6 ++- examples/svg/src/standalone.ts | 13 +++-- examples/svg/svg-prerendered.html | 4 +- .../{generic-views.ts => generic-views.tsx} | 34 ++++++++++++-- src/lib/model.ts | 47 +++++++++++++++++-- 5 files changed, 90 insertions(+), 14 deletions(-) rename src/lib/{generic-views.ts => generic-views.tsx} (52%) diff --git a/examples/svg/src/di.config.ts b/examples/svg/src/di.config.ts index 16a42cc8..a9ff339c 100644 --- a/examples/svg/src/di.config.ts +++ b/examples/svg/src/di.config.ts @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (c) 2017-2018 TypeFox and others. + * Copyright (c) 2017-2020 TypeFox 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 @@ -17,7 +17,8 @@ import { Container, ContainerModule } from "inversify"; import { TYPES, ConsoleLogger, LogLevel, loadDefaultModules, LocalModelSource, PreRenderedView, - SvgViewportView, ViewportRootElement, ShapedPreRenderedElement, configureModelElement + SvgViewportView, ViewportRootElement, ShapedPreRenderedElement, configureModelElement, + ForeignObjectElement, ForeignObjectView } from "../../../src"; export default () => { @@ -31,6 +32,7 @@ export default () => { const context = { bind, unbind, isBound, rebind }; configureModelElement(context, 'svg', ViewportRootElement, SvgViewportView); configureModelElement(context, 'pre-rendered', ShapedPreRenderedElement, PreRenderedView); + configureModelElement(context, 'foreign-object', ForeignObjectElement, ForeignObjectView); }); const container = new Container(); diff --git a/examples/svg/src/standalone.ts b/examples/svg/src/standalone.ts index 6636ed44..1a76e793 100644 --- a/examples/svg/src/standalone.ts +++ b/examples/svg/src/standalone.ts @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (c) 2017-2018 TypeFox and others. + * Copyright (c) 2017-2020 TypeFox 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 @@ -14,7 +14,7 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { LocalModelSource, TYPES, SModelRootSchema, ShapedPreRenderedElementSchema } from "../../../src"; +import { LocalModelSource, TYPES, SModelRootSchema, ShapedPreRenderedElementSchema, ForeignObjectElementSchema } from "../../../src"; import createContainer from "./di.config"; function loadFile(path: string): Promise { @@ -53,7 +53,14 @@ export default function runMulticore() { id: 'tiger', position: { x: 400, y: 0 }, code: tiger - } as ShapedPreRenderedElementSchema + } as ShapedPreRenderedElementSchema, + { + type: 'foreign-object', + id: 'direct-html', + position: { x: 50, y: 350 }, + size: { height: 50, width: 190 }, + code: "

This is a free-floating HTML paragraph!

" + } as ForeignObjectElementSchema ] }; diff --git a/examples/svg/svg-prerendered.html b/examples/svg/svg-prerendered.html index d8685b04..9ef70721 100644 --- a/examples/svg/svg-prerendered.html +++ b/examples/svg/svg-prerendered.html @@ -3,7 +3,7 @@ - Sprotty SVG Example + Sprotty Prerendered SVG / HTML Example @@ -13,7 +13,7 @@
-

Sprotty SVG Example

+

Sprotty Prerendered SVG / HTML Example

Help diff --git a/src/lib/generic-views.ts b/src/lib/generic-views.tsx similarity index 52% rename from src/lib/generic-views.ts rename to src/lib/generic-views.tsx index 422a410b..4c960f38 100644 --- a/src/lib/generic-views.ts +++ b/src/lib/generic-views.tsx @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (c) 2017-2018 TypeFox and others. + * Copyright (c) 2017-2020 TypeFox 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 @@ -14,12 +14,15 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +/** @jsx svg */ +import { injectable } from "inversify"; +import { svg } from 'snabbdom-jsx'; import virtualize from "snabbdom-virtualize/strings"; import { VNode } from "snabbdom/vnode"; import { IView, RenderingContext } from "../base/views/view"; -import { setNamespace } from "../base/views/vnode-utils"; -import { PreRenderedElement } from "./model"; -import { injectable } from "inversify"; +import { setNamespace, setAttr } from "../base/views/vnode-utils"; +import { ForeignObjectElement, PreRenderedElement } from "./model"; +import { getSubType } from "../base/model/smodel-utils"; @injectable() export class PreRenderedView implements IView { @@ -35,3 +38,26 @@ export class PreRenderedView implements IView { } } + +/** + * An SVG `foreignObject` view with a namespace specified by the provided `ForeignObjectElement`. + * Note that `foreignObject` may not be supported by all browsers or SVG viewers. + */ +@injectable() +export class ForeignObjectView implements IView { + render(model: ForeignObjectElement, context: RenderingContext): VNode { + const foreignObjectContents = virtualize(model.code); + const node = + + {foreignObjectContents} + + {context.renderChildren(model)} + ; + setAttr(node, 'class', model.type); + const subType = getSubType(model); + if (subType) setAttr(node, 'class', subType); + setNamespace(foreignObjectContents, model.namespace); + return node; + } +} diff --git a/src/lib/model.ts b/src/lib/model.ts index 133e620b..88cad2be 100644 --- a/src/lib/model.ts +++ b/src/lib/model.ts @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (c) 2017-2018 TypeFox and others. + * Copyright (c) 2017-2020 TypeFox 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 @@ -15,8 +15,8 @@ ********************************************************************************/ import { SModelRoot, SModelRootSchema, SChildElement, SModelElementSchema } from "../base/model/smodel"; -import { Point, Dimension, ORIGIN_POINT, EMPTY_DIMENSION, Bounds } from "../utils/geometry"; -import { BoundsAware, boundsFeature, Alignable, alignFeature } from "../features/bounds/model"; +import { Point, Dimension, ORIGIN_POINT, EMPTY_DIMENSION, Bounds, isValidDimension, EMPTY_BOUNDS } from "../utils/geometry"; +import { BoundsAware, boundsFeature, Alignable, alignFeature, isBoundsAware } from "../features/bounds/model"; import { Locateable, moveFeature } from "../features/move/model"; import { Selectable, selectFeature } from "../features/select/model"; import { SNode, SPort } from '../graph/sgraph'; @@ -136,3 +136,44 @@ export class ShapedPreRenderedElement extends PreRenderedElement implements Boun } } + +/** + * A `foreignObject` element to be transferred to the DOM within the SVG. + * + * This can be useful to to benefit from e.g. HTML rendering features, such as line wrapping, inside of + * the SVG diagram. Note that `foreignObject` is not supported by all browsers and SVG viewers may not + * support rendering the `foreignObject` content. + * + * If no dimensions are specified in the schema element, this element will obtain the dimension of + * its parent to fill the entire available room. Thus, this element requires specified bounds itself + * or bounds to be available for its parent. + */ +export class ForeignObjectElement extends ShapedPreRenderedElement { + namespace: string; + get bounds(): Bounds { + if (isValidDimension(this.size)) { + return { + x: this.position.x, + y: this.position.y, + width: this.size.width, + height: this.size.height + }; + } else if (isBoundsAware(this.parent)) { + return { + x: this.position.x, + y: this.position.y, + width: this.parent.bounds.width, + height: this.parent.bounds.height + }; + } + return EMPTY_BOUNDS; + } +} + +/** + * Serializable schema for ForeignObjectElement. + */ +export interface ForeignObjectElementSchema extends ShapedPreRenderedElementSchema { + /** The namespace to be assigned to the elements inside of the `foreignObject`. */ + namespace: string +} From 10c70c10f37d12f1674e5e6f209eabad4efa08fc Mon Sep 17 00:00:00 2001 From: Philip Langer Date: Tue, 24 Mar 2020 09:29:26 +0100 Subject: [PATCH 2/5] Enhance label editing with multiline support --- css/edit-label.css | 6 +- examples/svg/css/diagram.css | 17 +++++- examples/svg/src/di.config.ts | 30 ++++++++- examples/svg/src/standalone.ts | 23 ++++++- src/features/edit/edit-label-ui.ts | 98 +++++++++++++++++++----------- src/features/edit/model.ts | 5 +- 6 files changed, 136 insertions(+), 43 deletions(-) diff --git a/css/edit-label.css b/css/edit-label.css index 89ad37e5..0760b533 100644 --- a/css/edit-label.css +++ b/css/edit-label.css @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (c) 2019 EclipseSource and others. + * Copyright (c) 2019-2020 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 @@ -14,7 +14,7 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -.label-edit input { +.label-edit input, .label-edit textarea { background: rgba(255, 255, 255, 0.5); border-radius: 5px; border: 0; @@ -22,7 +22,7 @@ height: 99%; } -.label-edit input:focus { +.label-edit input:focus, .label-edit textarea:focus { outline: none; outline-offset: 0px; } diff --git a/examples/svg/css/diagram.css b/examples/svg/css/diagram.css index 3ace0bf5..df3aeb98 100644 --- a/examples/svg/css/diagram.css +++ b/examples/svg/css/diagram.css @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (c) 2017-2018 TypeFox and others. + * Copyright (c) 2017-2020 TypeFox 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 @@ -21,3 +21,18 @@ font-size: 14pt; text-anchor: middle; } + +.sprotty-node { + fill: darkorange; +} + +.child-foreign-object div { + margin: 5px; + color: seashell; + text-align: center; + user-select: none; +} + +.foreign-object { + user-select: none; +} \ No newline at end of file diff --git a/examples/svg/src/di.config.ts b/examples/svg/src/di.config.ts index a9ff339c..c591d8a4 100644 --- a/examples/svg/src/di.config.ts +++ b/examples/svg/src/di.config.ts @@ -18,12 +18,14 @@ import { Container, ContainerModule } from "inversify"; import { TYPES, ConsoleLogger, LogLevel, loadDefaultModules, LocalModelSource, PreRenderedView, SvgViewportView, ViewportRootElement, ShapedPreRenderedElement, configureModelElement, - ForeignObjectElement, ForeignObjectView + ForeignObjectElement, ForeignObjectView, RectangularNode, RectangularNodeView, moveFeature, + selectFeature, EditableLabel, editLabelFeature, WithEditableLabel, withEditLabelFeature, isEditableLabel } from "../../../src"; export default () => { require("../../../css/sprotty.css"); require("../css/diagram.css"); + require("../../../css/edit-label.css"); const svgModule = new ContainerModule((bind, unbind, isBound, rebind) => { rebind(TYPES.ILogger).to(ConsoleLogger).inSingletonScope(); @@ -33,6 +35,11 @@ export default () => { configureModelElement(context, 'svg', ViewportRootElement, SvgViewportView); configureModelElement(context, 'pre-rendered', ShapedPreRenderedElement, PreRenderedView); configureModelElement(context, 'foreign-object', ForeignObjectElement, ForeignObjectView); + configureModelElement(context, 'node', RectangleWithEditableLabel, RectangularNodeView, { enable: [withEditLabelFeature] }); + configureModelElement(context, 'child-foreign-object', EditableForeignObjectElement, ForeignObjectView, { + disable: [moveFeature, selectFeature], // disable move/select as we want the parent node to react to select/move + enable: [editLabelFeature] // enable editing -- see also EditableForeignObjectElement below + }); }); const container = new Container(); @@ -40,3 +47,24 @@ export default () => { container.load(svgModule); return container; }; + +export class RectangleWithEditableLabel extends RectangularNode implements WithEditableLabel { + get editableLabel() { + if (this.children.length > 0 && isEditableLabel(this.children[0])) { + return this.children[0] as EditableForeignObjectElement; + } + return undefined; + } +} + +export class EditableForeignObjectElement extends ForeignObjectElement implements EditableLabel { + readonly isMultiLine = true; + readonly editControlBoundsCorrection = { x: 5, y: 3, height: -7.5, width: -7.5 }; + + get text(): string { + return this.code; + } + set text(newText: string) { + this.code = newText.replace('\n', '
'); + } +} diff --git a/examples/svg/src/standalone.ts b/examples/svg/src/standalone.ts index 1a76e793..108682b9 100644 --- a/examples/svg/src/standalone.ts +++ b/examples/svg/src/standalone.ts @@ -14,7 +14,7 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { LocalModelSource, TYPES, SModelRootSchema, ShapedPreRenderedElementSchema, ForeignObjectElementSchema } from "../../../src"; +import { LocalModelSource, TYPES, SModelRootSchema, ShapedPreRenderedElementSchema, ForeignObjectElementSchema, SShapeElementSchema } from "../../../src"; import createContainer from "./di.config"; function loadFile(path: string): Promise { @@ -60,7 +60,26 @@ export default function runMulticore() { position: { x: 50, y: 350 }, size: { height: 50, width: 190 }, code: "

This is a free-floating HTML paragraph!

" - } as ForeignObjectElementSchema + } as ForeignObjectElementSchema, + { + id: 'foreign-object-in-shape', + type: 'node', + position: { + x: 50, + y: 90 + }, + size: { + height: 60, + width: 160 + }, + children: [ + { + type: 'child-foreign-object', + id: 'foreign-object-in-shape-contents', + code: "
This is HTML within an SVG rectangle!
" + } as ForeignObjectElementSchema + ] + } as SShapeElementSchema ] }; diff --git a/src/features/edit/edit-label-ui.ts b/src/features/edit/edit-label-ui.ts index 60c0a27f..43051449 100644 --- a/src/features/edit/edit-label-ui.ts +++ b/src/features/edit/edit-label-ui.ts @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (c) 2019 EclipseSource and others. + * Copyright (c) 2019-2020 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 @@ -25,7 +25,7 @@ import { SetUIExtensionVisibilityAction } from "../../base/ui-extensions/ui-exte import { DOMHelper } from "../../base/views/dom-helper"; import { ViewerOptions } from "../../base/views/viewer-options"; import { CommitModelAction } from "../../model-source/commit-model"; -import { matchesKeystroke } from "../../utils/keyboard"; +import { matchesKeystroke, KeyCode, KeyboardModifier } from "../../utils/keyboard"; import { getAbsoluteClientBounds } from "../bounds/model"; import { getZoom } from "../viewport/zoom"; import { @@ -44,8 +44,8 @@ export class EditLabelActionHandler implements IActionHandler { } export interface IEditLabelValidationDecorator { - decorate(input: HTMLInputElement, validationResult: EditLabelValidationResult): void; - dispose(input: HTMLInputElement): void; + decorate(input: HTMLInputElement | HTMLTextAreaElement, validationResult: EditLabelValidationResult): void; + dispose(input: HTMLInputElement | HTMLTextAreaElement): void; } @injectable() @@ -55,9 +55,6 @@ export class EditLabelUI extends AbstractUIExtension { readonly id = EditLabelUI.ID; readonly containerClass = "label-edit"; - /** The additional width to be added to the current label length for editing in pixel. Will be scaled depending on zoom level. */ - readonly additionalInputWidth = 100; - @inject(TYPES.IActionDispatcherProvider) public actionDispatcherProvider: IActionDispatcherProvider; @inject(TYPES.ViewerOptions) protected viewerOptions: ViewerOptions; @inject(TYPES.DOMHelper) protected domHelper: DOMHelper; @@ -65,6 +62,8 @@ export class EditLabelUI extends AbstractUIExtension { @inject(TYPES.IEditLabelValidationDecorator) @optional() public validationDecorator: IEditLabelValidationDecorator; protected inputElement: HTMLInputElement; + protected textAreaElement: HTMLTextAreaElement; + protected label?: EditableLabel & SModelElement; protected labelElement: HTMLElement | null; protected validationTimeout?: number = undefined; @@ -77,24 +76,41 @@ export class EditLabelUI extends AbstractUIExtension { protected initializeContents(containerElement: HTMLElement) { containerElement.style.position = 'absolute'; + this.inputElement = document.createElement('input'); - this.inputElement.onkeydown = (event) => this.handleKeyDown(event); - this.inputElement.onkeyup = (event) => this.validateLabelIfContentChange(event, this.inputElement.value); - this.inputElement.onblur = () => window.setTimeout(() => this.applyLabelEdit(), 200); - containerElement.appendChild(this.inputElement); + this.configureAndAdd(this.inputElement, containerElement); + this.inputElement.onkeydown = (event) => this.hideIfEscapeEvent(event); + this.inputElement.onkeydown = (event) => this.applyLabelEditOnEvent(event, 'Enter'); + + this.textAreaElement = document.createElement('textarea'); + this.configureAndAdd(this.textAreaElement, containerElement); + this.textAreaElement.onkeydown = (event) => this.hideIfEscapeEvent(event); + this.textAreaElement.onkeydown = (event) => this.applyLabelEditOnEvent(event, 'Enter', 'ctrl'); } - protected handleKeyDown(event: KeyboardEvent) { - this.hideIfEscapeEvent(event); - this.applyLabelEditIfEnterEvent(event); + protected configureAndAdd(element: HTMLElement, containerElement: HTMLElement) { + element.style.visibility = 'hidden'; + element.style.position = 'absolute'; + element.style.top = '0px'; + element.style.left = '0px'; + element.onkeyup = (event) => this.validateLabelIfContentChange(event, this.inputElement.value); + element.onblur = () => window.setTimeout(() => this.applyLabelEdit(), 200); + containerElement.appendChild(element); + } + + get editControl(): HTMLInputElement | HTMLTextAreaElement { + if (this.label && this.label.isMultiLine) { + return this.textAreaElement; + } + return this.inputElement; } protected hideIfEscapeEvent(event: KeyboardEvent) { if (matchesKeystroke(event, 'Escape')) { this.hide(); } } - protected applyLabelEditIfEnterEvent(event: KeyboardEvent) { - if (matchesKeystroke(event, 'Enter')) { + protected applyLabelEditOnEvent(event: KeyboardEvent, code?: KeyCode, ...modifiers: KeyboardModifier[]) { + if (matchesKeystroke(event, code ? code : 'Enter', ...modifiers)) { this.applyLabelEdit(); } } @@ -102,7 +118,7 @@ export class EditLabelUI extends AbstractUIExtension { protected validateLabelIfContentChange(event: KeyboardEvent, value: string) { if (this.previousLabelContent === undefined || this.previousLabelContent !== value) { this.previousLabelContent = value; - this.performLabelValidation(event, this.inputElement.value); + this.performLabelValidation(event, this.editControl.value); } } @@ -111,14 +127,14 @@ export class EditLabelUI extends AbstractUIExtension { return; } if (this.blockApplyEditOnInvalidInput) { - const result = await this.validateLabel(this.inputElement.value); + const result = await this.validateLabel(this.editControl.value); if ('error' === result.severity) { - this.inputElement.focus(); + this.editControl.focus(); return; } } this.actionDispatcherProvider() - .then((actionDispatcher) => actionDispatcher.dispatchAll([new ApplyLabelEditAction(this.labelId, this.inputElement.value), new CommitModelAction()])) + .then((actionDispatcher) => actionDispatcher.dispatchAll([new ApplyLabelEditAction(this.labelId, this.editControl.value), new CommitModelAction()])) .catch((reason) => this.logger.error(this, 'No action dispatcher available to execute apply label edit action', reason)); this.hide(); } @@ -148,13 +164,13 @@ export class EditLabelUI extends AbstractUIExtension { protected showValidationResult(result: EditLabelValidationResult) { this.clearValidationResult(); if (this.validationDecorator) { - this.validationDecorator.decorate(this.inputElement, result); + this.validationDecorator.decorate(this.editControl, result); } } protected clearValidationResult() { if (this.validationDecorator) { - this.validationDecorator.dispose(this.inputElement); + this.validationDecorator.dispose(this.editControl); } } @@ -164,10 +180,10 @@ export class EditLabelUI extends AbstractUIExtension { } super.show(root, ...contextElementIds); this.isActive = true; - this.inputElement.focus(); } hide(): void { + this.editControl.style.visibility = 'hidden'; super.hide(); this.clearValidationResult(); this.isActive = false; @@ -184,6 +200,8 @@ export class EditLabelUI extends AbstractUIExtension { this.setPosition(containerElement); this.applyTextContents(); this.applyFontStyling(); + this.editControl.style.visibility = 'visible'; + this.editControl.focus(); } protected setPosition(containerElement: HTMLElement) { @@ -193,24 +211,33 @@ export class EditLabelUI extends AbstractUIExtension { let height = 20; if (this.label) { + const zoom = getZoom(this.label); const bounds = getAbsoluteClientBounds(this.label, this.domHelper, this.viewerOptions); - x = bounds.x; - y = bounds.y; - height = bounds.height; - width = bounds.width + (this.additionalInputWidth * getZoom(this.label)); + x = bounds.x + (this.label.editControlBoundsCorrection ? this.label.editControlBoundsCorrection.x * zoom : 0); + y = bounds.y + (this.label.editControlBoundsCorrection ? this.label.editControlBoundsCorrection.y * zoom : 0); + height = bounds.height + (this.label.editControlBoundsCorrection ? this.label.editControlBoundsCorrection.height * zoom : 0); + width = bounds.width + (this.label.editControlBoundsCorrection ? this.label.editControlBoundsCorrection.width * zoom : 0); } containerElement.style.left = `${x}px`; containerElement.style.top = `${y}px`; containerElement.style.width = `${width}px`; + this.editControl.style.width = `${width}px`; containerElement.style.height = `${height}px`; - this.inputElement.style.position = 'absolute'; + this.editControl.style.height = `${height}px`; } protected applyTextContents() { if (this.label) { - this.inputElement.value = this.label.text; - this.inputElement.setSelectionRange(0, this.inputElement.value.length); + this.editControl.value = this.label.text; + if (this.editControl instanceof HTMLTextAreaElement) { + this.editControl.selectionStart = 0; + this.editControl.selectionEnd = 0; + this.editControl.scrollTop = 0; + this.editControl.scrollLeft = 0; + } else { + this.editControl.setSelectionRange(0, this.editControl.value.length); + } } } @@ -220,11 +247,12 @@ export class EditLabelUI extends AbstractUIExtension { if (this.labelElement) { this.labelElement.style.visibility = 'hidden'; const style = window.getComputedStyle(this.labelElement); - this.inputElement.style.font = style.font; - this.inputElement.style.fontStyle = style.fontStyle; - this.inputElement.style.fontFamily = style.fontFamily; - this.inputElement.style.fontSize = scaledFont(style.fontSize, getZoom(this.label)); - this.inputElement.style.fontWeight = style.fontWeight; + this.editControl.style.font = style.font; + this.editControl.style.fontStyle = style.fontStyle; + this.editControl.style.fontFamily = style.fontFamily; + this.editControl.style.fontSize = scaledFont(style.fontSize, getZoom(this.label)); + this.editControl.style.fontWeight = style.fontWeight; + this.editControl.style.lineHeight = style.lineHeight; } } } diff --git a/src/features/edit/model.ts b/src/features/edit/model.ts index 4d0ffff2..fd5c4d66 100644 --- a/src/features/edit/model.ts +++ b/src/features/edit/model.ts @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (c) 2017-2019 TypeFox and others. + * Copyright (c) 2017-2020 TypeFox 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 @@ -17,6 +17,7 @@ import { SModelElement } from '../../base/model/smodel'; import { SRoutableElement } from '../routing/model'; import { SModelExtension } from '../../base/model/smodel-extension'; +import { Bounds } from '../../utils/geometry'; export const editFeature = Symbol('editFeature'); @@ -28,6 +29,8 @@ export const editLabelFeature = Symbol('editLabelFeature'); export interface EditableLabel extends SModelExtension { text: string; + readonly isMultiLine?: boolean; + readonly editControlBoundsCorrection?: Bounds; } export function isEditableLabel(element: T): element is T & EditableLabel { From 48ca10ebd0194123efcb0c71790ea030f8401b09 Mon Sep 17 00:00:00 2001 From: Philip Langer Date: Thu, 26 Mar 2020 13:01:38 +0100 Subject: [PATCH 3/5] Make id and containerClass overridable Readonly properties aren't overridable by subclasses which limits the customizability. If e.g. the id is not overridable, we cannot register more than one UIExtension that inherits from the edit label UI. --- src/base/ui-extensions/ui-extension-registry.ts | 2 +- src/base/ui-extensions/ui-extension.ts | 13 +++++++------ src/features/command-palette/command-palette.ts | 6 +++--- src/features/edit/edit-label-ui.ts | 9 ++++----- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/base/ui-extensions/ui-extension-registry.ts b/src/base/ui-extensions/ui-extension-registry.ts index cb90d8e0..8c1b321d 100644 --- a/src/base/ui-extensions/ui-extension-registry.ts +++ b/src/base/ui-extensions/ui-extension-registry.ts @@ -27,7 +27,7 @@ import { IUIExtension } from "./ui-extension"; export class UIExtensionRegistry extends InstanceRegistry { constructor(@multiInject(TYPES.IUIExtension) @optional() extensions: (IUIExtension)[] = []) { super(); - extensions.forEach((extension) => this.register(extension.id, extension)); + extensions.forEach((extension) => this.register(extension.id(), extension)); } } diff --git a/src/base/ui-extensions/ui-extension.ts b/src/base/ui-extensions/ui-extension.ts index ef5cff3e..7dd7755c 100644 --- a/src/base/ui-extensions/ui-extension.ts +++ b/src/base/ui-extensions/ui-extension.ts @@ -23,7 +23,7 @@ import { ViewerOptions } from "../views/viewer-options"; * A UI extension displaying additional UI elements on top of a sprotty diagram. */ export interface IUIExtension { - readonly id: string; + id(): string; show(root: Readonly, ...contextElementIds: string[]): void; hide(): void; } @@ -36,11 +36,12 @@ export abstract class AbstractUIExtension implements IUIExtension { @inject(TYPES.ViewerOptions) protected options: ViewerOptions; @inject(TYPES.ILogger) protected logger: ILogger; - abstract readonly id: string; - abstract readonly containerClass: string; protected containerElement: HTMLElement; protected activeElement: Element | null; + abstract id(): string; + abstract containerClass(): string; + show(root: Readonly, ...contextElementIds: string[]): void { this.activeElement = document.activeElement; if (!this.containerElement) { @@ -78,11 +79,11 @@ export abstract class AbstractUIExtension implements IUIExtension { } protected getOrCreateContainer(baseDivId: string): HTMLElement { - let container = document.getElementById(this.id); + let container = document.getElementById(this.id()); if (container === null) { container = document.createElement('div'); - container.id = baseDivId + "_" + this.id; - container.classList.add(this.containerClass); + container.id = baseDivId + "_" + this.id(); + container.classList.add(this.containerClass()); } return container; } diff --git a/src/features/command-palette/command-palette.ts b/src/features/command-palette/command-palette.ts index a07bd932..45297bd2 100644 --- a/src/features/command-palette/command-palette.ts +++ b/src/features/command-palette/command-palette.ts @@ -42,9 +42,6 @@ export class CommandPalette extends AbstractUIExtension { static readonly ID = "command-palette"; static readonly isInvokePaletteKey = (event: KeyboardEvent) => matchesKeystroke(event, 'Space', 'ctrl'); - readonly id = CommandPalette.ID; - readonly containerClass = "command-palette"; - protected loadingIndicatorClasses = ['loading']; protected xOffset = 20; protected yOffset = 20; @@ -64,6 +61,9 @@ export class CommandPalette extends AbstractUIExtension { @inject(TYPES.DOMHelper) protected domHelper: DOMHelper; @inject(MousePositionTracker) protected mousePositionTracker: MousePositionTracker; + public id() { return CommandPalette.ID; } + public containerClass() { return "command-palette"; } + show(root: Readonly, ...contextElementIds: string[]) { super.show(root, ...contextElementIds); this.paletteIndex = 0; diff --git a/src/features/edit/edit-label-ui.ts b/src/features/edit/edit-label-ui.ts index 43051449..9bdb1ef2 100644 --- a/src/features/edit/edit-label-ui.ts +++ b/src/features/edit/edit-label-ui.ts @@ -52,9 +52,6 @@ export interface IEditLabelValidationDecorator { export class EditLabelUI extends AbstractUIExtension { static readonly ID = "editLabelUi"; - readonly id = EditLabelUI.ID; - readonly containerClass = "label-edit"; - @inject(TYPES.IActionDispatcherProvider) public actionDispatcherProvider: IActionDispatcherProvider; @inject(TYPES.ViewerOptions) protected viewerOptions: ViewerOptions; @inject(TYPES.DOMHelper) protected domHelper: DOMHelper; @@ -72,6 +69,9 @@ export class EditLabelUI extends AbstractUIExtension { protected isCurrentLabelValid: boolean = true; protected previousLabelContent?: string; + public id() { return EditLabelUI.ID; } + public containerClass() { return "label-edit"; } + protected get labelId() { return this.label ? this.label.id : 'unknown'; } protected initializeContents(containerElement: HTMLElement) { @@ -79,12 +79,10 @@ export class EditLabelUI extends AbstractUIExtension { this.inputElement = document.createElement('input'); this.configureAndAdd(this.inputElement, containerElement); - this.inputElement.onkeydown = (event) => this.hideIfEscapeEvent(event); this.inputElement.onkeydown = (event) => this.applyLabelEditOnEvent(event, 'Enter'); this.textAreaElement = document.createElement('textarea'); this.configureAndAdd(this.textAreaElement, containerElement); - this.textAreaElement.onkeydown = (event) => this.hideIfEscapeEvent(event); this.textAreaElement.onkeydown = (event) => this.applyLabelEditOnEvent(event, 'Enter', 'ctrl'); } @@ -94,6 +92,7 @@ export class EditLabelUI extends AbstractUIExtension { element.style.top = '0px'; element.style.left = '0px'; element.onkeyup = (event) => this.validateLabelIfContentChange(event, this.inputElement.value); + element.onkeydown = (event) => this.hideIfEscapeEvent(event); element.onblur = () => window.setTimeout(() => this.applyLabelEdit(), 200); containerElement.appendChild(element); } From 5a0b08d31db766317dd93903378373fe1fd1cd8a Mon Sep 17 00:00:00 2001 From: Philip Langer Date: Fri, 27 Mar 2020 17:42:39 +0100 Subject: [PATCH 4/5] Only remove child if it actually exists --- src/features/command-palette/command-palette.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/features/command-palette/command-palette.ts b/src/features/command-palette/command-palette.ts index 45297bd2..7ffde09d 100644 --- a/src/features/command-palette/command-palette.ts +++ b/src/features/command-palette/command-palette.ts @@ -168,7 +168,9 @@ export class CommandPalette extends AbstractUIExtension { } protected onLoaded(success: 'success' | 'error') { - this.containerElement.removeChild(this.loadingIndicator); + if (this.containerElement.contains(this.loadingIndicator)) { + this.containerElement.removeChild(this.loadingIndicator); + } } protected renderLabeledActionSuggestion(item: LabeledAction, value: string) { From 70252d490de07800e4fb90e34d0c74b0c9451e80 Mon Sep 17 00:00:00 2001 From: Philip Langer Date: Mon, 30 Mar 2020 10:29:37 +0200 Subject: [PATCH 5/5] Let labels overwrite static edit control dimension By default, use a static dimension for edit labels, but let SModel elements overwrite this static dimensions. This change also fixes the key listeners (Esc to cancel editing) didn't work anymore. --- examples/svg/src/di.config.ts | 2 +- src/features/edit/edit-label-ui.ts | 14 +++++++------- src/features/edit/model.ts | 5 +++-- src/lib/generic-views.tsx | 3 --- 4 files changed, 11 insertions(+), 13 deletions(-) diff --git a/examples/svg/src/di.config.ts b/examples/svg/src/di.config.ts index c591d8a4..862db89b 100644 --- a/examples/svg/src/di.config.ts +++ b/examples/svg/src/di.config.ts @@ -59,7 +59,7 @@ export class RectangleWithEditableLabel extends RectangularNode implements WithE export class EditableForeignObjectElement extends ForeignObjectElement implements EditableLabel { readonly isMultiLine = true; - readonly editControlBoundsCorrection = { x: 5, y: 3, height: -7.5, width: -7.5 }; + get editControlDimension() { return { width: this.bounds.width, height: this.bounds.height }; } get text(): string { return this.code; diff --git a/src/features/edit/edit-label-ui.ts b/src/features/edit/edit-label-ui.ts index 9bdb1ef2..0c417419 100644 --- a/src/features/edit/edit-label-ui.ts +++ b/src/features/edit/edit-label-ui.ts @@ -91,9 +91,9 @@ export class EditLabelUI extends AbstractUIExtension { element.style.position = 'absolute'; element.style.top = '0px'; element.style.left = '0px'; - element.onkeyup = (event) => this.validateLabelIfContentChange(event, this.inputElement.value); - element.onkeydown = (event) => this.hideIfEscapeEvent(event); - element.onblur = () => window.setTimeout(() => this.applyLabelEdit(), 200); + element.addEventListener('keydown', (event) => this.hideIfEscapeEvent(event)); + element.addEventListener('keyup', (event) => this.validateLabelIfContentChange(event, this.inputElement.value)); + element.addEventListener('blur', () => window.setTimeout(() => this.applyLabelEdit(), 200)); containerElement.appendChild(element); } @@ -212,10 +212,10 @@ export class EditLabelUI extends AbstractUIExtension { if (this.label) { const zoom = getZoom(this.label); const bounds = getAbsoluteClientBounds(this.label, this.domHelper, this.viewerOptions); - x = bounds.x + (this.label.editControlBoundsCorrection ? this.label.editControlBoundsCorrection.x * zoom : 0); - y = bounds.y + (this.label.editControlBoundsCorrection ? this.label.editControlBoundsCorrection.y * zoom : 0); - height = bounds.height + (this.label.editControlBoundsCorrection ? this.label.editControlBoundsCorrection.height * zoom : 0); - width = bounds.width + (this.label.editControlBoundsCorrection ? this.label.editControlBoundsCorrection.width * zoom : 0); + x = bounds.x + (this.label.editControlPositionCorrection ? this.label.editControlPositionCorrection.x : 0) * zoom; + y = bounds.y + (this.label.editControlPositionCorrection ? this.label.editControlPositionCorrection.y : 0) * zoom; + height = (this.label.editControlDimension ? this.label.editControlDimension.height : height) * zoom; + width = (this.label.editControlDimension ? this.label.editControlDimension.width : width) * zoom; } containerElement.style.left = `${x}px`; diff --git a/src/features/edit/model.ts b/src/features/edit/model.ts index fd5c4d66..f95b879e 100644 --- a/src/features/edit/model.ts +++ b/src/features/edit/model.ts @@ -17,7 +17,7 @@ import { SModelElement } from '../../base/model/smodel'; import { SRoutableElement } from '../routing/model'; import { SModelExtension } from '../../base/model/smodel-extension'; -import { Bounds } from '../../utils/geometry'; +import { Point, Dimension } from '../../utils/geometry'; export const editFeature = Symbol('editFeature'); @@ -30,7 +30,8 @@ export const editLabelFeature = Symbol('editLabelFeature'); export interface EditableLabel extends SModelExtension { text: string; readonly isMultiLine?: boolean; - readonly editControlBoundsCorrection?: Bounds; + readonly editControlDimension?: Dimension; + readonly editControlPositionCorrection?: Point; } export function isEditableLabel(element: T): element is T & EditableLabel { diff --git a/src/lib/generic-views.tsx b/src/lib/generic-views.tsx index 4c960f38..780d5142 100644 --- a/src/lib/generic-views.tsx +++ b/src/lib/generic-views.tsx @@ -22,7 +22,6 @@ import { VNode } from "snabbdom/vnode"; import { IView, RenderingContext } from "../base/views/view"; import { setNamespace, setAttr } from "../base/views/vnode-utils"; import { ForeignObjectElement, PreRenderedElement } from "./model"; -import { getSubType } from "../base/model/smodel-utils"; @injectable() export class PreRenderedView implements IView { @@ -55,8 +54,6 @@ export class ForeignObjectView implements IView { {context.renderChildren(model)} ; setAttr(node, 'class', model.type); - const subType = getSubType(model); - if (subType) setAttr(node, 'class', subType); setNamespace(foreignObjectContents, model.namespace); return node; }