Files
spring-flo/src/lib/editor/editor.component.ts
2018-12-03 14:36:55 -05:00

1254 lines
40 KiB
TypeScript

import { Component, Input, Output, ElementRef, EventEmitter, OnInit, OnDestroy, ViewEncapsulation } from '@angular/core';
import { debounceTime } from 'rxjs/operators';
import { dia } from 'jointjs';
import { Flo } from '../shared/flo-common';
import { Shapes, Constants } from '../shared/shapes';
import { Utils } from './editor-utils';
import { CompositeDisposable, Disposable } from 'ts-disposables';
import * as _$ from 'jquery';
import * as _ from 'lodash';
import { Observable, Subject, BehaviorSubject } from 'rxjs';
const joint: any = Flo.joint;
const $: any = _$;
export interface VisibilityState {
visibility: string;
children: Array<VisibilityState>;
}
@Component({
selector: 'flo-editor',
templateUrl: './editor.component.html',
styleUrls: ['./../../../node_modules/jointjs/dist/joint.css', './editor.component.scss'],
encapsulation: ViewEncapsulation.None
})
export class EditorComponent implements OnInit, OnDestroy {
/**
* Joint JS Graph object representing the Graph model
*/
private graph: dia.Graph;
/**
* Joint JS Paper object representing the canvas control containing the graph view
*/
private paper: dia.Paper;
/**
* Currently selected element
*/
private _selection: dia.CellView;
/**
* Current DnD descriptor for frag in progress
*/
private highlighted: Flo.DnDDescriptor;
/**
* Flag specifying whether the Flo-Editor is in read-only mode.
*/
private _readOnlyCanvas = false;
/**
* Grid size
*/
private _gridSize = 1;
private _hiddenPalette = false;
private editorContext: Flo.EditorContext;
private textToGraphEventEmitter = new EventEmitter<void>();
private graphToTextEventEmitter = new EventEmitter<void>();
private _graphToTextSyncEnabled = true;
private validationEventEmitter = new EventEmitter<void>();
private _disposables = new CompositeDisposable();
private _dslText = '';
private textToGraphConversionCompleted = new Subject<void>();
private graphToTextConversionCompleted = new Subject<void>();
private paletteReady = new BehaviorSubject<boolean>(false);
/**
* Metamodel. Retrieves metadata about elements that can be shown in Flo
*/
@Input()
metamodel: Flo.Metamodel;
/**
* Renders elements.
*/
@Input()
renderer: Flo.Renderer;
/**
* Editor. Provides domain specific editing capabilities on top of standard Flo features
*/
@Input()
editor: Flo.Editor;
/**
* Size (Width) of the palette
*/
@Input()
paletteSize: number;
/**
* Min zoom percent value
*/
@Input()
minZoom = 5;
/**
* Max zoom percent value
*/
@Input()
maxZoom = 400;
/**
* Zoom percent increment/decrement step
*/
@Input()
zoomStep = 5;
@Input()
paperPadding = 0;
@Output()
floApi = new EventEmitter<Flo.EditorContext>();
@Output()
validationMarkers = new EventEmitter<Map<string | number, Array<Flo.Marker>>>();
@Output()
contentValidated = new EventEmitter<boolean>();
@Output()
private dslChange = new EventEmitter<string>();
private _resizeHandler = () => this.autosizePaper();
constructor(private element: ElementRef) {
let self = this;
this.editorContext = new (class DefaultRunnableContext implements Flo.EditorContext {
set zoomPercent(percent: number) {
self.zoomPercent = percent;
}
get zoomPercent(): number {
return self.zoomPercent;
}
set noPalette(noPalette: boolean) {
self.noPalette = noPalette;
}
get noPalette(): boolean {
return self.noPalette;
}
set gridSize(gridSize: number) {
self.gridSize = gridSize;
}
get gridSize(): number {
return self.gridSize;
}
set readOnlyCanvas(readOnly: boolean) {
self.readOnlyCanvas = readOnly;
}
get readOnlyCanvas(): boolean {
return self.readOnlyCanvas;
}
setDsl(dsl: string) {
self.dsl = dsl;
}
updateGraph(): Promise<any> {
return self.updateGraphRepresentation();
}
updateText(): Promise<any> {
return self.updateTextRepresentation();
}
performLayout(): Promise<void> {
return self.doLayout();
}
clearGraph(): Promise<void> {
self.selection = undefined;
self.graph.clear();
if (self.metamodel && self.metamodel.load && self.editor && self.editor.setDefaultContent) {
return self.metamodel.load().then(data => {
self.editor.setDefaultContent(this, data);
if (!self.graphToTextSync) {
return self.updateTextRepresentation();
}
});
} else {
if (!self.graphToTextSync) {
return self.updateTextRepresentation();
}
}
}
getGraph() {
return self.graph;
}
getPaper() {
return self.paper;
}
get graphToTextSync(): boolean {
return self.graphToTextSync;
}
set graphToTextSync(sync: boolean) {
self.graphToTextSync = sync;
}
getMinZoom() {
return self.minZoom;
}
getMaxZoom() {
return self.maxZoom;
}
getZoomStep() {
return self.zoomStep;
}
fitToPage() {
self.fitToPage();
}
createNode(metadata: Flo.ElementMetadata, props?: Map<string, any>, position?: dia.Point): dia.Element {
return self.createNode(metadata, props, position);
}
createLink(source: Flo.LinkEnd, target: Flo.LinkEnd, metadata?: Flo.ElementMetadata, props?: Map<string, any>): dia.Link {
return self.createLink(source, target, metadata, props);
}
get selection(): dia.CellView {
return self.selection;
}
set selection(newSelection: dia.CellView) {
self.selection = newSelection;
}
deleteSelectedNode(): void {
if (self.selection) {
if (self.editor && self.editor.preDelete) {
self.editor.preDelete(self.editorContext, self.selection.model);
} else {
if (self.selection.model instanceof joint.dia.Element) {
self.graph.getConnectedLinks(self.selection.model).forEach((l: dia.Link) => l.remove());
}
}
self.selection.model.remove();
self.selection = undefined;
}
}
get textToGraphConversionObservable(): Observable<void> {
return self.textToGraphConversionCompleted;
}
get graphToTextConversionObservable(): Observable<void> {
return self.graphToTextConversionCompleted;
}
get paletteReady(): Observable<boolean> {
return self.paletteReady;
}
})();
}
ngOnInit() {
this.initGraph();
this.initPaper();
this.initGraphListeners();
this.initPaperListeners();
this.initMetamodel();
$(window).on('resize', this._resizeHandler);
this._disposables.add(Disposable.create(() => $(window).off('resize', this._resizeHandler)));
/*
* Execute resize to get the right size for the SVG element on the editor canvas.
* Executed via timeout to let angular render the DOM first and elements to have the right width and height
*/
window.setTimeout(this._resizeHandler);
this.floApi.emit(this.editorContext);
}
ngOnDestroy() {
this._disposables.dispose();
}
get noPalette(): boolean {
return this._hiddenPalette;
}
set noPalette(hidden: boolean) {
this._hiddenPalette = hidden;
// If palette is not shown ensure that canvas starts from the left==0!
if (hidden) {
$('#paper-container', this.element.nativeElement).css('left', 0);
}
}
get graphToTextSync(): boolean {
return this._graphToTextSyncEnabled;
}
set graphToTextSync(sync: boolean) {
this._graphToTextSyncEnabled = sync;
// Try commenting the sync out. Just set the flag but don't kick off graph->text conversion
// this.performGraphToTextSyncing();
}
private performGraphToTextSyncing() {
if (this._graphToTextSyncEnabled) {
this.graphToTextEventEmitter.emit();
}
}
createHandle(element: dia.CellView, kind: string, action: () => void, location: dia.Point): dia.Element {
if (!location) {
let bbox: any = (<any>element.model).getBBox();
location = bbox.origin().offset(bbox.width / 2, bbox.height / 2);
}
let handle = Shapes.Factory.createHandle({
renderer: this.renderer,
paper: this.paper,
parent: element.model,
kind: kind,
position: location
});
const view: dia.ElementView = this.paper.findViewByModel(handle);
view.on('cell:pointerdown', () => {
if (action) {
action();
}
});
view.on('cell:mouseover', () => {
handle.attr('image/filter', {
name: 'dropShadow',
args: {dx: 1, dy: 1, blur: 1, color: 'black'}
});
});
view.on('cell:mouseout', () => {
handle.removeAttr('image/filter');
});
view.setInteractivity(false);
return handle;
}
removeEmbeddedChildrenOfType(element: dia.Cell, types: Array<string>): void {
let embeds = element.getEmbeddedCells();
for (let i = 0; i < embeds.length; i++) {
if (types.indexOf(embeds[i].get('type')) >= 0) {
embeds[i].remove();
}
}
}
get selection(): dia.CellView {
return this._selection;
}
set selection(newSelection: dia.CellView) {
if (newSelection && (newSelection.model.get('type') === joint.shapes.flo.DECORATION_TYPE || newSelection.model.get('type') === joint.shapes.flo.HANDLE_TYPE)) {
newSelection = this.paper.findViewByModel(this.graph.getCell(newSelection.model.get('parent')));
}
if (newSelection && (!newSelection.model.attr('metadata') || newSelection.model.attr('metadata/metadata/unselectable'))) {
newSelection = undefined;
}
if (newSelection !== this._selection) {
if (this._selection) {
const elementview = this.paper.findViewByModel(this._selection.model);
if (elementview) { // May have been removed from the graph
this.removeEmbeddedChildrenOfType(elementview.model, joint.shapes.flo.HANDLE_TYPE);
elementview.unhighlight();
}
}
if (newSelection) {
newSelection.highlight();
if (this.editor && this.editor.createHandles) {
this.editor.createHandles(this.editorContext, (owner: dia.CellView, kind: string, action: () => void, location: dia.Point) => this.createHandle(owner, kind, action, location), newSelection);
}
}
this._selection = newSelection;
}
}
get readOnlyCanvas(): boolean {
return this._readOnlyCanvas;
}
set readOnlyCanvas(value: boolean) {
if (this._readOnlyCanvas === value) {
// Nothing to do
return
}
if (value) {
this.selection = undefined;
}
if (this.graph) {
this.graph.getLinks().forEach((link: dia.Link) => {
if (value) {
link.attr('.link-tools/display', 'none');
link.attr('.marker-vertices/display', 'none');
link.attr('.connection-wrap/display', 'none');
} else {
link.removeAttr('.link-tools/display');
if (this.editor && this.editor.allowLinkVertexEdit) {
link.removeAttr('.marker-vertices/display');
}
link.removeAttr('.connection-wrap/display');
}
});
}
this._readOnlyCanvas = value;
}
/**
* Displays graphical feedback for the drag and drop in progress based on current drag and drop descriptor object
*
* @param dragDescriptor DnD info object. Has on info on graph node being dragged (drag source) and what it is
* being dragged over at the moment (drop target)
*/
showDragFeedback(dragDescriptor: Flo.DnDDescriptor): void {
if (this.editor && this.editor.showDragFeedback) {
this.editor.showDragFeedback(this.editorContext, dragDescriptor);
} else {
let magnet: SVGElement;
if (dragDescriptor.source && dragDescriptor.source.view) {
joint.V(dragDescriptor.source.view.el).addClass('dnd-source-feedback');
if (dragDescriptor.source.cssClassSelector) {
magnet = Flo.findMagnetByClass(dragDescriptor.source.view, dragDescriptor.source.cssClassSelector);
if (magnet) {
joint.V(magnet).addClass('dnd-source-feedback');
}
}
}
if (dragDescriptor.target && dragDescriptor.target.view) {
joint.V(dragDescriptor.target.view.el).addClass('dnd-target-feedback');
if (dragDescriptor.target.cssClassSelector) {
magnet = Flo.findMagnetByClass(dragDescriptor.target.view, dragDescriptor.target.cssClassSelector);
if (magnet) {
joint.V(magnet).addClass('dnd-target-feedback');
}
}
}
}
}
/**
* Hides graphical feedback for the drag and drop in progress based on current drag and drop descriptor object
*
* @param dragDescriptor DnD info object. Has on info on graph node being dragged (drag source) and what it is
* being dragged over at the moment (drop target)
*/
hideDragFeedback(dragDescriptor: Flo.DnDDescriptor): void {
if (this.editor && this.editor.hideDragFeedback) {
this.editor.hideDragFeedback(this.editorContext, dragDescriptor);
} else {
let magnet: SVGElement;
if (dragDescriptor.source && dragDescriptor.source.view) {
joint.V(dragDescriptor.source.view.el).removeClass('dnd-source-feedback');
if (dragDescriptor.source.cssClassSelector) {
magnet = Flo.findMagnetByClass(dragDescriptor.source.view, dragDescriptor.source.cssClassSelector);
if (magnet) {
joint.V(magnet).removeClass('dnd-source-feedback');
}
}
}
if (dragDescriptor.target && dragDescriptor.target.view) {
joint.V(dragDescriptor.target.view.el).removeClass('dnd-target-feedback');
if (dragDescriptor.target.cssClassSelector) {
magnet = Flo.findMagnetByClass(dragDescriptor.target.view, dragDescriptor.target.cssClassSelector);
if (magnet) {
joint.V(magnet).removeClass('dnd-target-feedback');
}
}
}
}
}
/**
* Sets the new DnD info object - the descriptor for DnD
*
* @param dragDescriptor DnD info object. Has on info on graph node being dragged (drag source) and what it is
* being dragged over at the moment (drop target)
*/
setDragDescriptor(dragDescriptor?: Flo.DnDDescriptor): void {
if (this.highlighted === dragDescriptor) {
return;
}
if (this.highlighted && dragDescriptor && _.isEqual(this.highlighted.sourceComponent, dragDescriptor.sourceComponent)) {
if (this.highlighted.source === dragDescriptor.source && this.highlighted.target === dragDescriptor.target) {
return;
}
if (this.highlighted.source &&
dragDescriptor.source &&
this.highlighted.target &&
dragDescriptor.target &&
this.highlighted.source.view.model === dragDescriptor.source.view.model &&
this.highlighted.source.cssClassSelector === dragDescriptor.source.cssClassSelector &&
this.highlighted.target.view.model === dragDescriptor.target.view.model &&
this.highlighted.target.cssClassSelector === dragDescriptor.target.cssClassSelector) {
return;
}
}
if (this.highlighted) {
this.hideDragFeedback(this.highlighted);
}
this.highlighted = dragDescriptor;
if (this.highlighted) {
this.showDragFeedback(this.highlighted);
}
}
/**
* Handles DnD events when a node is being dragged over canvas
*
* @param draggedView The Joint JS view object being dragged
* @param targetUnderMouse The Joint JS view under mouse cursor
* @param x X coordinate of the mouse on the canvas
* @param y Y coordinate of the mosue on the canvas
* @param context DnD context (palette or canvas)
*/
handleNodeDragging(draggedView: dia.CellView, targetUnderMouse: dia.CellView, x: number, y: number, sourceComponent: string) {
if (this.editor && this.editor.calculateDragDescriptor) {
this.setDragDescriptor(this.editor.calculateDragDescriptor(this.editorContext, draggedView, targetUnderMouse, joint.g.point(x, y), sourceComponent));
}
}
/**
* Handles DnD drop event when a node is being dragged and dropped on the main canvas
*/
handleNodeDropping() {
if (this.highlighted && this.editor && this.editor.handleNodeDropping) {
this.editor.handleNodeDropping(this.editorContext, this.highlighted);
}
this.setDragDescriptor(undefined);
}
/**
* Hides DOM Node (used to determine drop target DOM element)
* @param domNode DOM node to hide
* @returns
*/
private _hideNode(domNode: HTMLElement): VisibilityState {
let oldVisibility: VisibilityState = {
visibility: domNode.style ? domNode.style.display : undefined,
children: []
};
for (let i = 0; i < domNode.children.length; i++) {
let node = domNode.children.item(i);
if (node instanceof HTMLElement) {
oldVisibility.children.push(this._hideNode(<HTMLElement> node));
}
}
domNode.style.display = 'none';
return oldVisibility;
}
/**
* Restored DOM node original visibility (used to determine drop target DOM element)
* @param domNode DOM node to restore visibility of
* @param oldVisibility original visibility parameter
*/
_restoreNodeVisibility(domNode: HTMLElement, oldVisibility: VisibilityState) {
if (domNode.style) {
domNode.style.display = oldVisibility.visibility;
}
let j = 0;
for (let i = 0; i < domNode.childNodes.length; i++) {
if (j < oldVisibility.children.length) {
let node = domNode.children.item(i);
if (node instanceof HTMLElement) {
this._restoreNodeVisibility(<HTMLElement> node, oldVisibility.children[j++]);
}
}
}
}
/**
* Unfortunately we can't just use event.target because often draggable shape on the canvas overlaps the target.
* We can easily find the element(s) at location, but only nodes :-( Unclear how to find links at location
* (bounding box of a link for testing is bad).
* The result of that is that links can only be the drop target when dragging from the palette currently.
* When DnDing shapes on the canvas drop target cannot be a link.
*
* Excluded views enables you to choose to filter some possible answers (useful in the case where elements are stacked
* - e.g. Drag-n-Drop)
*/
getTargetViewFromEvent(event: MouseEvent, x: number, y: number, excludeViews: Array<dia.CellView> = []): dia.CellView {
if (!x && !y) {
let l = this.paper.snapToGrid({x: event.clientX, y: event.clientY});
x = l.x;
y = l.y;
}
// TODO: See if next code paragraph is needed. Most likely it's just code executed for nothing
// let elements = this.graph.findModelsFromPoint(joint.g.point(x, y));
// let underMouse = elements.find(e => !_.isUndefined(excludeViews.find(x => x === this.paper.findViewByModel(e))));
// if (underMouse) {
// return underMouse;
// }
let oldVisibility = excludeViews.map(_x => this._hideNode(_x.el));
let targetElement = document.elementFromPoint(event.clientX, event.clientY);
excludeViews.forEach((excluded, i) => {
this._restoreNodeVisibility(excluded.el, oldVisibility[i]);
});
return this.paper.findView($(targetElement));
}
handleDnDFromPalette(dndEvent: Flo.DnDEvent) {
switch (dndEvent.type) {
case Flo.DnDEventType.DRAG:
this.handleDragFromPalette(dndEvent);
break;
case Flo.DnDEventType.DROP:
this.handleDropFromPalette(dndEvent);
break;
default:
break;
}
}
handleDragFromPalette(dnDEvent: Flo.DnDEvent) {
console.debug('Dragging from palette');
if (dnDEvent.view && !this.readOnlyCanvas) {
let location = this.paper.snapToGrid({x: dnDEvent.event.clientX, y: dnDEvent.event.clientY});
this.handleNodeDragging(dnDEvent.view, this.getTargetViewFromEvent(dnDEvent.event, location.x, location.y, [dnDEvent.view]), location.x, location.y, Constants.PALETTE_CONTEXT);
}
}
createNode(metadata: Flo.ElementMetadata, props: Map<string, any>, position: dia.Point): dia.Element {
return Shapes.Factory.createNode({
renderer: this.renderer,
paper: this.paper,
metadata: metadata,
props: props,
position: position
});
}
createLink(source: Flo.LinkEnd, target: Flo.LinkEnd, metadata: Flo.ElementMetadata, props: Map<string, any>): dia.Link {
return Shapes.Factory.createLink({
renderer: this.renderer,
paper: this.paper,
source: source,
target: target,
metadata: metadata,
props: props
});
}
handleDropFromPalette(event: Flo.DnDEvent) {
let cellview = event.view;
let evt = event.event;
if (this.paper.el === evt.target || $.contains(this.paper.el, evt.target)) {
if (this.readOnlyCanvas) {
this.setDragDescriptor(undefined);
} else {
let metadata = cellview.model.attr('metadata');
let props = cellview.model.attr('props');
let position = this.paper.snapToGrid({x: evt.clientX, y: evt.clientY});
/* Calculate target element before creating the new
* element under mouse location. Otherwise target
* element would be the newly created element because
* it's under the mouse pointer
*/
let targetElement = this.getTargetViewFromEvent(evt, position.x, position.y, [ event.view ]);
let newNode = this.createNode(metadata, props, position);
let newView = this.paper.findViewByModel(newNode);
this.handleNodeDragging(newView, targetElement, position.x, position.y, Constants.PALETTE_CONTEXT);
this.handleNodeDropping();
}
}
}
private fitToContent(gridWidth: number, gridHeight: number, padding: number, opt: any) {
const paper = this.paper;
if (joint.util.isObject(gridWidth)) {
// first parameter is an option object
opt = gridWidth;
gridWidth = opt.gridWidth || 1;
gridHeight = opt.gridHeight || 1;
padding = opt.padding || 0;
} else {
opt = opt || {};
gridWidth = gridWidth || 1;
gridHeight = gridHeight || 1;
padding = padding || 0;
}
const paddingJson = joint.util.normalizeSides(padding);
// Calculate the paper size to accomodate all the graph's elements.
const bbox = joint.V(paper.viewport).getBBox();
const currentScale = paper.scale();
const currentTranslate = paper.translate();
bbox.x *= currentScale.sx;
bbox.y *= currentScale.sy;
bbox.width *= currentScale.sx;
bbox.height *= currentScale.sy;
let calcWidth = Math.max((bbox.width + bbox.x) / gridWidth, 1) * gridWidth;
let calcHeight = Math.max((bbox.height + bbox.y) / gridHeight, 1) * gridHeight;
let tx = 0;
let ty = 0;
if ((opt.allowNewOrigin === 'negative' && bbox.x < 0) || (opt.allowNewOrigin === 'positive' && bbox.x >= 0) || opt.allowNewOrigin === 'any') {
tx = (-bbox.x / gridWidth) * gridWidth;
tx += paddingJson.left;
} else if (opt.allowNewOrigin === 'same') {
tx = currentTranslate.tx;
}
calcWidth += tx;
if ((opt.allowNewOrigin === 'negative' && bbox.y < 0) || (opt.allowNewOrigin === 'positive' && bbox.y >= 0) || opt.allowNewOrigin === 'any') {
ty = (-bbox.y / gridHeight) * gridHeight;
ty += paddingJson.top;
} else if (opt.allowNewOrigin === 'same') {
ty = currentTranslate.ty;
}
calcHeight += ty;
calcWidth += paddingJson.right;
calcHeight += paddingJson.bottom;
// Make sure the resulting width and height are greater than minimum.
calcWidth = Math.max(calcWidth, opt.minWidth || 0);
calcHeight = Math.max(calcHeight, opt.minHeight || 0);
// Make sure the resulting width and height are lesser than maximum.
calcWidth = Math.min(calcWidth, opt.maxWidth || Number.MAX_VALUE);
calcHeight = Math.min(calcHeight, opt.maxHeight || Number.MAX_VALUE);
const dimensionChange = calcWidth !== paper.options.width || calcHeight !== paper.options.height;
const originChange = tx !== currentTranslate.tx || ty !== currentTranslate.ty;
// Change the dimensions only if there is a size discrepency or an origin change
if (originChange) {
paper.translate(tx, ty);
}
if (dimensionChange) {
paper.setDimensions(calcWidth, calcHeight);
}
}
autosizePaper(): void {
let parent = $('#paper-container', this.element.nativeElement);
const parentWidth = parent.innerWidth();
const parentHeight = parent.innerHeight();
this.fitToContent(this.gridSize, this.gridSize, this.paperPadding, {
minWidth: parentWidth,
minHeight: parentHeight,
allowNewOrigin: 'same'
});
}
fitToPage(): void {
let parent = $('#paper-container', this.element.nativeElement);
let minScale = this.minZoom / 100;
let maxScale = 2;
const parentWidth = parent.innerWidth();
const parentHeight = parent.innerHeight();
this.paper.scaleContentToFit({
padding: this.paperPadding,
minScaleX: minScale,
minScaleY: minScale,
maxScaleX: maxScale,
maxScaleY: maxScale,
fittingBBox: {x: 0, y: 0, width: parentWidth, height: parentHeight}
});
/**
* Size the canvas appropriately and allow origin movement
*/
this.fitToContent(this.gridSize, this.gridSize, this.paperPadding, {
minWidth: parentWidth,
minHeight: parentHeight,
maxWidth: parentWidth,
maxHeight: parentHeight,
allowNewOrigin: 'any'
});
}
get zoomPercent(): number {
return Math.round(joint.V(this.paper.viewport).scale().sx * 100);
}
set zoomPercent(percent: number) {
if (!isNaN(percent)) {
if (percent < this.minZoom) {
percent = this.minZoom;
} else if (percent >= this.maxZoom) {
percent = this.maxZoom;
} else {
if (percent <= 0) {
percent = 0.00001;
}
}
this.paper.scale(percent / 100, percent / 100);
}
}
get gridSize(): number {
return this._gridSize;
}
set gridSize(size: number) {
if (!isNaN(size) && size >= 1) {
this._gridSize = size;
if (this.paper) {
this.paper.setGridSize(size);
}
}
}
validateContent(): Promise<any> {
return new Promise(resolve => {
if (this.editor && this.editor.validate) {
return this.editor
.validate(this.graph, this.dsl, this.editorContext)
.then(allMarkers => {
this.graph.getCells()
.forEach((cell: dia.Cell) => this.markElement(cell, allMarkers.has(cell.id) ? allMarkers.get(cell.id) : []));
this.validationMarkers.emit(allMarkers);
this.contentValidated.emit(true);
resolve();
});
} else {
resolve();
}
});
}
markElement(cell: dia.Cell, markers: Array<Flo.Marker>) {
let errorMessages = markers.map(m => m.message);
let errorCell = cell.getEmbeddedCells().find((e: dia.Cell) => e.attr('./kind') === Constants.ERROR_DECORATION_KIND);
if (errorCell) {
if (errorMessages.length === 0) {
errorCell.remove();
} else {
// Without rewrite we merge this list with existing errors
errorCell.attr('messages', errorMessages, {rewrite: true});
}
} else if (errorMessages.length > 0) {
let error = Shapes.Factory.createDecoration({
renderer: this.renderer,
paper: this.paper,
parent: cell,
kind: Constants.ERROR_DECORATION_KIND,
messages: errorMessages
});
let pt: dia.Point;
const view = this.paper.findViewByModel(error);
if (cell instanceof joint.dia.Element) {
pt = (<dia.Element> cell).getBBox().topRight().offset(-error.get('size').width, 0);
error.set('position', pt);
(<dia.ElementView>view).setInteractivity(false);
} else {
// TODO: do something for the link perhaps?
}
}
}
doLayout(): Promise<void> {
if (this.renderer && this.renderer.layout) {
return this.renderer.layout(this.paper);
}
}
@Input()
set dsl(dslText: string) {
if (this._dslText !== dslText) {
this._dslText = dslText;
this.textToGraphEventEmitter.emit();
}
}
get dsl(): string {
return this._dslText;
}
/**
* Ask the server to parse the supplied text into a JSON graph of nodes and links,
* then update the view based on that new information.
*/
updateGraphRepresentation(): Promise<any> {
console.debug(`Updating graph to represent '${this._dslText}'`);
if (this.metamodel && this.metamodel.textToGraph) {
return this.metamodel.textToGraph(this.editorContext, this._dslText).then(() => {
this.textToGraphConversionCompleted.next();
return this.validateContent()
});
} else {
this.textToGraphConversionCompleted.next();
return this.validateContent();
}
}
updateTextRepresentation(): Promise<any> {
if (this.metamodel && this.metamodel.graphToText) {
return this.metamodel.graphToText(this.editorContext).then(text => {
if (this._dslText !== text) {
this._dslText = text;
this.dslChange.emit(text);
}
this.graphToTextConversionCompleted.next();
return this.validateContent();
})
.catch(error => {
// Validation may reveal why the graph couldn't be
// converted so let it run
this.graphToTextConversionCompleted.next();
return this.validateContent();
});
} else {
this.graphToTextConversionCompleted.next();
return this.validateContent();
}
}
initMetamodel() {
this.metamodel.load().then(data => {
this.updateGraphRepresentation();
let textSyncSubscription = this.graphToTextEventEmitter.pipe(debounceTime(100)).subscribe(() => {
if (this._graphToTextSyncEnabled) {
this.updateTextRepresentation();
}
});
this._disposables.add(Disposable.create(() => textSyncSubscription.unsubscribe()));
// Setup content validated event emitter. Emit not validated when graph to text conversion required
let graphValidatedSubscription1 = this.graphToTextEventEmitter.subscribe(() => this.contentValidated.emit(false));
this._disposables.add(Disposable.create(() => graphValidatedSubscription1.unsubscribe));
// let validationSubscription = this.validationEventEmitter.pipe(debounceTime(100)).subscribe(() => this.validateGraph());
// this._disposables.add(Disposable.create(() => validationSubscription.unsubscribe()));
let graphSyncSubscription = this.textToGraphEventEmitter.pipe(debounceTime(300)).subscribe(() => this.updateGraphRepresentation());
this._disposables.add(Disposable.create(() => graphSyncSubscription.unsubscribe()));
// Setup content validated event emitter. Emit not validated when text to graph conversion required
let graphValidatedSubscription2 = this.textToGraphEventEmitter.subscribe(() => this.contentValidated.emit(false));
this._disposables.add(Disposable.create(() => graphValidatedSubscription2.unsubscribe));
if (this.editor && this.editor.setDefaultContent) {
this.editor.setDefaultContent(this.editorContext, data);
}
});
}
initGraph() {
this.graph = new joint.dia.Graph();
this.graph.set('type', Constants.CANVAS_CONTEXT);
this.graph.set('paperPadding', this.paperPadding);
}
handleNodeCreation(node: dia.Element) {
node.on('change:size', this._resizeHandler);
node.on('change:position', this._resizeHandler);
if (node.attr('metadata')) {
node.on('change:attrs', (cell: dia.Element, attrs: any, changeData: any) => {
let propertyPath = changeData ? changeData.propertyPath : undefined;
if (propertyPath) {
let propAttr = propertyPath.substr(propertyPath.indexOf('/') + 1);
if (propAttr.indexOf('metadata') === 0 ||
propAttr.indexOf('props') === 0 ||
(this.renderer && this.renderer.isSemanticProperty && this.renderer.isSemanticProperty(propAttr, node))) {
this.performGraphToTextSyncing();
}
if (this.renderer && this.renderer.refreshVisuals) {
this.renderer.refreshVisuals(node, propAttr, this.paper);
}
}
});
}
}
/**
* Forwards a link event occurrence to any handlers in the editor service, if they are defined. Event examples
* are 'change:source', 'change:target'.
*/
handleLinkEvent(event: string, link: dia.Link) {
if (this.renderer && this.renderer.handleLinkEvent) {
this.renderer.handleLinkEvent(this.editorContext, event, link);
}
}
handleLinkCreation(link: dia.Link) {
this.handleLinkEvent('add', link);
link.on('change:source', (l: dia.Link) => {
this.autosizePaper();
let newSourceId = l.get('source').id;
let oldSourceId = l.previous('source').id;
if (newSourceId !== oldSourceId) {
this.performGraphToTextSyncing();
}
this.handleLinkEvent('change:source', l);
});
link.on('change:target', (l: dia.Link) => {
this.autosizePaper();
let newTargetId = l.get('target').id;
let oldTargetId = l.previous('target').id;
if (newTargetId !== oldTargetId) {
this.performGraphToTextSyncing();
}
this.handleLinkEvent('change:target', l);
});
link.on('change:vertices', this._resizeHandler);
link.on('change:attrs', (cell: dia.Link, attrs: any, changeData: any) => {
let propertyPath = changeData ? changeData.propertyPath : undefined;
if (propertyPath) {
let propAttr = propertyPath.substr(propertyPath.indexOf('/') + 1);
if (propAttr.indexOf('metadata') === 0 ||
propAttr.indexOf('props') === 0 ||
(this.renderer && this.renderer.isSemanticProperty && this.renderer.isSemanticProperty(propAttr, link))) {
let sourceId = link.get('source').id;
let targetId = link.get('target').id;
this.performGraphToTextSyncing();
}
if (this.renderer && this.renderer.refreshVisuals) {
this.renderer.refreshVisuals(link, propAttr, this.paper);
}
}
});
this.paper.findViewByModel(link).on('link:options', () => this.handleLinkEvent('options', link));
if (this.readOnlyCanvas) {
link.attr('.link-tools/display', 'none');
}
}
initGraphListeners() {
this.graph.on('add', (element: dia.Cell) => {
if (element instanceof joint.dia.Link) {
this.handleLinkCreation(<dia.Link> element);
} else if (element instanceof joint.dia.Element) {
this.handleNodeCreation(<dia.Element> element);
}
if (element.get('type') === joint.shapes.flo.NODE_TYPE || element.get('type') === joint.shapes.flo.LINK_TYPE) {
this.performGraphToTextSyncing();
}
this.autosizePaper();
});
this.graph.on('remove', (element: dia.Cell) => {
if (element instanceof joint.dia.Link) {
this.handleLinkEvent('remove', <dia.Link> element);
}
if (this.selection && this.selection.model === element) {
this.selection = undefined;
}
if (element.isLink()) {
window.setTimeout(() => this.performGraphToTextSyncing(), 100);
} else if (element.get('type') === joint.shapes.flo.NODE_TYPE) {
this.performGraphToTextSyncing();
}
this.autosizePaper();
});
// Set if link is fan-routed. Should be called before routing call
this.graph.on('change:vertices', (link: dia.Link, changed: any, opt: any) => {
if (opt.fanRouted) {
link.set('fanRouted', true);
} else {
link.unset('fanRouted');
}
});
// adjust vertices when a cell is removed or its source/target was changed
this.graph.on('add remove change:source change:target change:vertices change:position', _.partial(Utils.fanRoute, this.graph));
}
initPaperListeners() {
// http://stackoverflow.com/questions/20463533/how-to-add-an-onclick-event-to-a-joint-js-element
this.paper.on('cell:pointerclick', (cellView: dia.CellView) => {
if (!this.readOnlyCanvas) {
this.selection = cellView;
}
}
);
this.paper.on('blank:pointerclick', () => {
this.selection = undefined;
});
this.paper.on('scale', this._resizeHandler);
this.paper.on('all', function() {
if (Utils.isCustomPaperEvent(arguments)) {
arguments[2].trigger.apply(arguments[2], [arguments[0], arguments[1], arguments[3], arguments[4]]);
}
});
this.paper.on('dragging-node-over-canvas', (dndEvent: Flo.DnDEvent) => {
console.debug(`Canvas DnD type = ${dndEvent.type}`);
let location = this.paper.snapToGrid({x: dndEvent.event.clientX, y: dndEvent.event.clientY});
switch (dndEvent.type) {
case Flo.DnDEventType.DRAG:
this.handleNodeDragging(dndEvent.view, this.getTargetViewFromEvent(dndEvent.event, location.x, location.y, [ dndEvent.view ]), location.x, location.y, Constants.CANVAS_CONTEXT);
break;
case Flo.DnDEventType.DROP:
this.handleNodeDropping();
break;
default:
break;
}
});
// JointJS now no longer grabs focus if working in a paper element - crude...
$('#flow-view', this.element.nativeElement).on('mousedown', () => {
$('#palette-filter-textfield', this.element.nativeElement).focus();
});
}
initPaper(): void {
let options: dia.Paper.Options = {
el: $('#paper', this.element.nativeElement),
gridSize: this._gridSize,
drawGrid: true,
model: this.graph,
elementView: this.renderer && this.renderer.getNodeView ? this.renderer.getNodeView() : joint.shapes.flo.ElementView/*joint.dia.ElementView*/,
linkView: this.renderer && this.renderer.getLinkView ? this.renderer.getLinkView() : joint.shapes.flo.LinkView,
// Enable link snapping within 25px lookup radius
snapLinks: { radius: 25 }, // http://www.jointjs.com/tutorial/ports
defaultLink: /*this.renderer && this.renderer.createDefaultLink ? this.renderer.createDefaultLink: new joint.shapes.flo.Link*/
(cellView: dia.CellView, magnet: SVGElement) => {
if (this.renderer && this.renderer.createLink) {
let linkEnd: Flo.LinkEnd = {
id: cellView.model.id
};
if (magnet) {
linkEnd.selector = cellView.getSelector(magnet, undefined);
}
if (magnet.getAttribute('port')) {
linkEnd.port = magnet.getAttribute('port');
}
if (magnet.getAttribute('port') === 'input') {
return this.renderer.createLink(undefined, linkEnd);
} else {
return this.renderer.createLink(linkEnd, undefined);
}
} else {
return new joint.shapes.flo.Link();
}
},
// decide whether to create a link if the user clicks a magnet
validateMagnet: (cellView: dia.CellView, magnet: SVGElement) => {
if (this.readOnlyCanvas) {
return false;
} else {
if (this.editor && this.editor.validatePort) {
return this.editor.validatePort(this.editorContext, cellView, magnet);
} else {
return true;
}
}
},
interactive: (cellView: dia.CellView, event: string) => {
if (this.readOnlyCanvas) {
return false;
} else {
if (this.editor && this.editor.interactive) {
if (typeof this.editor.interactive === 'function') {
// Type for interactive is wrong in JointJS have to cast to <any>
return <any>this.editor.interactive(cellView, event);
} else {
return this.editor.interactive
}
}
return true
}
},
highlighting: this.editor && this.editor.highlighting ? this.editor.highlighting : {
'default': {
name: 'addClass',
options: {
className: 'highlighted'
}
}
},
markAvailable: true
};
if (this.renderer && this.renderer.getLinkAnchorPoint) {
options.linkConnectionPoint = this.renderer.getLinkAnchorPoint;
}
if (this.editor && this.editor.validateLink) {
const self = this;
options.validateConnection = (cellViewS: dia.CellView, magnetS: SVGElement, cellViewT: dia.CellView, magnetT: SVGElement, end: 'source' | 'target', linkView: dia.LinkView) =>
self!.editor!.validateLink(this.editorContext, cellViewS, magnetS, cellViewT, magnetT, end === 'source', linkView);
}
// The paper is what will represent the graph on the screen
this.paper = new joint.dia.Paper(options);
this._disposables.add(Disposable.create(() => this.paper.remove()));
}
updatePaletteReadyState(ready: boolean) {
this.paletteReady.next(ready);
}
}