Build infrastructure and Angular 15 upgrade

This commit is contained in:
aboyko
2023-02-28 19:22:33 -05:00
parent aa27ebeb3f
commit edfcdb2b02
101 changed files with 8009 additions and 25625 deletions

View File

@@ -0,0 +1,237 @@
.flow-view {
height: 100%;
}
.header {
font-weight: 400;
font-family: "Varela Round",sans-serif;
font-size: 36px;
color: #eeeeee;
padding: 2px;
background-color: #34302d;
border: none;
border-top: 4px solid #6db33f;
z-index: 1;
}
body {
background-color: #eeeeee;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-o-user-select: none;
user-select: none;
}
.control-button {
width: 16px;
height: 16px;
}
.header-small {
font: 300 24px "Helvetica Neue";
}
pre {
font-size: 18px;
}
.border-selected {
stroke: #34302d;
stroke-width: 3;
}
.controls {
border-radius: 2px;
border: solid;
border-color: #6db33f;
padding: 5px;
margin-top: 3px;
background-color: #eeeeee;
border-width: 1px;
}
.button {
background-color: #34302d;
background-image: none;
border-radius: 2px;
color: #f1f1f1;
font-size: 14px;
line-height: 14px;
font-family: Montserrat,sans-serif;
border: 2px solid #6db33f;
padding: 5px 20px;
text-shadow: none;
}
.button span {
background-color: #34302d;
background-image: none;
border-radius: 2px;
color: #f1f1f1;
font-size: 14px;
line-height: 14px;
font-family: Montserrat,sans-serif;
border: 2px solid #6db33f;
padding: 5px 20px;
text-shadow: none;
}
.button input {
background-color: #34302d;
background-image: none;
color: #f1f1f1;
font-size: 14px;
font-family: Montserrat,sans-serif;
text-shadow: none;
border: 0px;
text-align:right;
}
.button input[type=range] {
display: inline;
width: 100px;
}
button.on {
background-color: #5fa134;
}
.flow-definition-container {
border: 1px solid;
border-color: #6db33f;
border-radius: 2px;
margin-top: 3px;
background-color: #ffffff;
font-family: monospace;
z-index: 2;
width:100%;
height:100px;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
}
textarea:input {
outline: none;
border: 1px solid #6db33f;
}
textarea:input:focus {
outline: none;
border: 1px solid #000000;
}
.flow-definition {
border: 5px;
height:100%;
width:100%;
font-size: 16px;
resize: none;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
}
/* The text label on the nodes */
.label {
font-family: 'Lucida Console';
font-size: 12px;
}
/* The class for the 'icon/unicode_char' on the nodes */
.label2 {
font-size: 18px;
}
[ng\:cloak], [ng-cloak], [data-ng-cloak], [x-ng-cloak], .ng-cloak, .x-ng-cloak {
display: none !important;
}
#flo-container {
height: 800px;
}
/* Modules DnD shape animation */
@keyframes flash-stroke-width {
0% { stroke-width: 1; }
50% { stroke-width: 4; }
100% { stroke-width: 1; }
}
/*.dnd-source-feedback.joint-element .box {*/
/*animation: flash-stroke-width 2s linear infinite;*/
/*}*/
.dnd-target-feedback.joint-element .box {
animation: flash-stroke-width 2s linear infinite;
}
/* Ports DnD animation */
.dnd-source-feedback.input-port {
animation: flash-stroke-width 2s linear infinite;
}
.dnd-source-feedback.output-port {
animation: flash-stroke-width 2s linear infinite;
}
.dnd-target-feedback.input-port {
animation: flash-stroke-width 2s linear infinite;
}
.dnd-target-feedback.output-port {
animation: flash-stroke-width 2s linear infinite;
}
/* Links DnD feedback animation */
.dnd-source-feedback .connection {
animation: flash-stroke-width 2s linear infinite;
}
.dnd-target-feedback .connection {
animation: flash-stroke-width 2s linear infinite;
}
.properties-group-container .property-row {
display: block;
}
.df-property-row {
width: 100%;
display:inline-table;
}
.df-property-label-cell {
width: 50%;
}
.df-property-container {
width: 100%
}
.df-property-control {
width: 100%;
}
.invalid-property-value .df-property-label-cell {
color: red;
}
.invalid-property-value .df-property-control {
outline-color: red;
border-color: red;
}
.validation-error-block {
color: red;
}
.code-editor-host {
height: 120px;
width: 284px;
border: 1px solid;
border-color: lightgrey;
}
.CodeMirror pre.CodeMirror-placeholder {
color: lightgray;
}

View File

@@ -0,0 +1,61 @@
<!--
<my-lib></my-lib>
<h3>Meaning is: {{meaning}}</h3>
<flo-editor></flo-editor>
-->
<div id="header" class="header">
Spring Flo Sample
</div>
<div id="flo-container">
<flo-editor (floApi)="editorContext = $event" [metamodel]="metamodel" [renderer]="renderer" [editor]="editor"
[paletteSize]="paletteSize" [paletteEntryPadding]="{width: 4, height: 4}" [(dsl)]="dsl"
[paperPadding]="20" (validationMarkers)="markersChanged($event)" searchFilterPlaceHolder="Apps..."
(onProperties)="openPropertiesDialog($event)">
<div header>
<div id="controls" class="controls">
<button class="button" id="clearGraph" (click)="editorContext.clearGraph()">Create New Flow</button>
<!-- let's use the 'x' control on the nodes themselves when they are selected
<button class="button" id="deleteSelectedNode" ng-click="flo.deleteSelectedNode()">Delete Selected Node</button>
-->
<button class="button" id="performLayout" (click)="arrangeAll()">Reset Layout</button>
<button class="button" id="sync" (click)="editorContext.graphToTextSync = !editorContext.graphToTextSync"
[ngClass]="{on:editorContext.graphToTextSync}">Synchronize
</button>
<button class="button" id="readOnly" (click)="editorContext.readOnlyCanvas = !editorContext.readOnlyCanvas"
[ngClass]="{on:editorContext.readOnlyCanvas}">Read-Only
</button>
<button class="button" id="noPalette" (click)="editorContext.noPalette = !editorContext.noPalette"
[ngClass]="{on:!editorContext.noPalette}">Palette
</button>
<button class="button" id="editor" (click)="dslEditor = !dslEditor" [ngClass]="{on:dslEditor}">{{dslEditor ?
'TextArea' : 'Editor'}}
</button>
<span class="button">
<label>Zoom: </label>
<input id="zoomInput" type="text" [(ngModel)]="editorContext.zoomPercent" size="3">
<label>%</label>
<input type="range" [(ngModel)]="editorContext.zoomPercent" [step]="editorContext.getZoomStep()"
[max]="editorContext.getMaxZoom()" [min]="editorContext.getMinZoom()" data-type="range" name="range"
class="range">
</span>
<span class="button">
<label>Grid Size: </label>
<input id="gridSizeInput" type="text" [(ngModel)]="editorContext.gridSize" size="2">
<label>pixels</label>
<input type="range" [(ngModel)]="editorContext.gridSize" step="1" max="50" min="1" data-type="range"
name="range" class="range">
</span>
</div>
<div class="flow-definition-container">
<dsl-editor *ngIf="dslEditor" [(dsl)]="dsl" [line-numbers]="true" [line-wrapping]="true"
(blur)="editorContext.graphToTextSync=true" (focus)="editorContext.graphToTextSync=false"
placeholder="Enter stream definition..."></dsl-editor>
<textarea *ngIf="!dslEditor" id="flow-definition" class="flow-definition"
placeholder="Enter stream definition..."
[value]="dsl" (keyup)="processKeyUp($event)" (blur)="editorContext.graphToTextSync=true"
(focus)="editorContext.graphToTextSync=false"></textarea>
</div>
</div>
</flo-editor>
</div>

View File

@@ -0,0 +1,57 @@
import { Component, ViewEncapsulation } from '@angular/core';
import { Flo } from 'spring-flo';
import { BsModalService } from 'ngx-bootstrap/modal';
const { Metamodel } = require('./metamodel');
const { Renderer } = require('./renderer');
const { Editor } = require('./editor');
// Code editor used from Flo requires the follwoing CM extensions
import 'codemirror/mode/javascript/javascript';
import 'codemirror/mode/ruby/ruby';
import 'codemirror/mode/clike/clike';
import 'codemirror/addon/lint/javascript-lint';
import {PropertiesEditorService} from './properties-editor.service';
import {dia} from 'jointjs';
@Component({
selector: 'demo-app',
templateUrl: './app.component.html',
styleUrls: [ './app.component.css' ],
encapsulation: ViewEncapsulation.None
})
export class AppComponent {
metamodel: Flo.Metamodel;
renderer: Flo.Renderer;
editor: Flo.Editor;
dsl: string;
dslEditor = false;
editorContext: Flo.EditorContext;
paletteSize = 170;
constructor(private propertiesEditor: PropertiesEditorService) {
this.metamodel = new Metamodel();
this.renderer = new Renderer();
this.editor = new Editor(propertiesEditor);
this.dsl = '';
}
arrangeAll() {
this.editorContext.performLayout().then(() => this.editorContext.fitToPage());
}
markersChanged(markers: Map<string | number, Array<Flo.Marker>>) {
console.log('MARKERS: ' + JSON.stringify(markers));
}
openPropertiesDialog(cell: dia.Cell) {
this.propertiesEditor.openPropertiesDialog(cell)
}
processKeyUp(evt: KeyboardEvent): void {
this.dsl = (evt.target as HTMLTextAreaElement).value;
}
}

View File

@@ -0,0 +1,16 @@
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { BrowserModule } from '@angular/platform-browser';
import { FloModule } from 'spring-flo';
import { ModalModule } from 'ngx-bootstrap/modal';
import { PropertiesDialogComponent } from './properties.dialog.component';
import { AppComponent } from './app.component';
@NgModule({
imports: [ BrowserModule, FormsModule, FloModule, ModalModule.forRoot() ],
declarations: [ AppComponent, PropertiesDialogComponent ],
entryComponents: [ PropertiesDialogComponent ],
bootstrap: [ AppComponent ]
})
export class AppModule { }

552
projects/demo/app/editor.ts Normal file
View File

@@ -0,0 +1,552 @@
/*
* Copyright 2016 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Flo, Constants, Properties } from 'spring-flo';
import { dia, g } from 'jointjs';
import {PropertiesEditorService} from './properties-editor.service';
const joint: any = Flo.joint;
/**
* @author Alex Boyko
* @author Andy Clement
*/
export class Editor implements Flo.Editor {
get highlighting() {
return {
connecting: {
name: 'addParentClass',
options: {
className: 'connecting'
}
},
'default': {
name: 'addClass',
options: {
className: 'highlighted'
}
}
};
}
constructor(private propertiesEditor: PropertiesEditorService) {}
createHandles(context : Flo.EditorContext, createHandle : (owner : dia.CellView, kind : string, action : () => void, location : dia.Point) => void, owner : dia.CellView) {
if (owner.model instanceof joint.dia.Element) {
let bbox : any = (<dia.Element> owner.model).getBBox();
// Remove handle
createHandle(owner, Constants.REMOVE_HANDLE_TYPE, context.deleteSelectedNode, bbox.origin().offset(bbox.width + 3, bbox.height + 3));
// Properties handle
if (!owner.model.get('metadata')?.unresolved) {
createHandle(owner, Constants.PROPERTIES_HANDLE_TYPE, () => this.propertiesEditor.openPropertiesDialog(owner.model), bbox.origin().offset(-14, bbox.height + 3));
}
}
}
validatePort(context: Flo.EditorContext, view : dia.ElementView, magnet : SVGElement) {
return true;
}
validateLink(context: Flo.EditorContext, cellViewS : dia.ElementView, magnetS : SVGElement, cellViewT : dia.ElementView, magnetT : SVGElement, isSource : boolean, linkView : dia.LinkView) {
// Prevent linking from input ports.
if (magnetS && magnetS.getAttribute('port') === 'input') {
return false;
}
// Prevent linking from output ports to input ports within one element.
if (cellViewS === cellViewT) {
return false;
}
// Prevent linking to input ports.
if (magnetT && magnetT.getAttribute('port') === 'output') {
return false;
}
return cellViewS.model && cellViewT.model && !(cellViewS.model instanceof joint.shapes.flo.ErrorDecoration) && !(cellViewT.model instanceof joint.shapes.flo.ErrorDecoration);
}
preDelete(context : Flo.EditorContext, deletedElement : dia.Cell) {
if (deletedElement instanceof joint.dia.Element) {
this.repairDamage(context, <dia.Element> deletedElement);
}
return true;
}
handleNodeDropping(context : Flo.EditorContext, dragDescriptor : Flo.DnDDescriptor) {
let relinking = dragDescriptor.sourceComponent === Constants.PALETTE_CONTEXT;
let graph = context.getGraph();
let source = dragDescriptor.source ? dragDescriptor.source.view.model : undefined;
let target = dragDescriptor.target ? dragDescriptor.target.view.model : undefined;
if (target instanceof joint.dia.Element && target.get('metadata')?.name) {
// Custom handling allowing a node to be dropped on a port and inserting
// it into the flow directly without the user needing to do more link
// drawing
let type = source.get('metadata')?.name;
if (dragDescriptor.target.cssClassSelector === '.output-port') {
this.moveNodeOnNode(context, <dia.Element> source, <dia.Element> target, 'right', true);
relinking = true;
} else if (dragDescriptor.target.cssClassSelector === '.input-port') {
this.moveNodeOnNode(context, <dia.Element> source, <dia.Element> target, 'left', true);
relinking = true;
}
} else if (target instanceof joint.dia.Link) { // jshint ignore:line
// Custom handling allowing a node to be dropped on a link and inserting
// itself in that link without the user needing to do more link drawing
this.moveNodeOnLink(context, <dia.Element> source, <dia.Link> target, false);
relinking = true;
}
// Turn off auto layout
// if (relinking) {
// flo.performLayout();
// }
}
calculateDragDescriptor(context : Flo.EditorContext, draggedView : dia.CellView, targetUnderMouse : dia.CellView, point : g.Point, sourceComponent : string) : Flo.DnDDescriptor {
let source = draggedView.model;
let sourceGroup = source.get('metadata')?.group;
// Find closest port
let range = 30;
let graph = context.getGraph();
let paper = context.getPaper();
let closestData : Flo.DnDDescriptor;
let minDistance = Number.MAX_VALUE;
let maxIcomingLinks = sourceGroup === 'source' ? 0 : 1;
let maxOutgoingLinks = sourceGroup === 'sink' ? 0 : 1;
let hasIncomingPort = sourceGroup !== 'source';
let hasOutgoingPort = sourceGroup !== 'sink';
if (!hasIncomingPort && !hasOutgoingPort) {
return;
}
let elements = graph.findModelsInArea(joint.g.rect(point.x - range, point.y - range, 2 * range, 2 * range)); // jshint ignore:line
if (Array.isArray(elements)) {
elements.forEach(function(model) {
let view = paper.findViewByModel(model);
if (view && view !== draggedView && model instanceof joint.dia.Element) { // jshint ignore:line
let targetGroup = model.get('metadata')?.group;
let targetMaxIcomingLinks = targetGroup === 'source' ? 0 : 1;
let targetMaxOutgoingLinks = targetGroup === 'sink' ? 0 : 1;
let targetHasIncomingPort = targetGroup !== 'source';
let targetHasOutgoingPort = targetGroup !== 'sink';
view.$('[magnet]').each((index : number, magnet : HTMLElement) => {
let type = magnet.getAttribute('port');
if ((type === 'input' && targetHasIncomingPort && hasOutgoingPort) || (type === 'output' && targetHasOutgoingPort && hasIncomingPort)) {
let bbox = joint.V(magnet).bbox(false, paper.viewport); // jshint ignore:line
let distance = point.distance({
x: bbox.x + bbox.width / 2,
y: bbox.y + bbox.height / 2
});
if (distance < range && distance < minDistance) {
minDistance = distance;
closestData = {
sourceComponent: sourceComponent,
source: {
view: draggedView,
cssClassSelector: type === 'output' ? '.input-port' : '.output-port'
},
target: {
view: view,
cssClassSelector: '.' + type+'-port'
},
range: minDistance
};
}
}
});
}
});
}
if (closestData) {
return closestData;
}
// Check if drop on a link is allowed
if (targetUnderMouse instanceof joint.dia.LinkView &&
sourceGroup === 'processor' &&
graph.getConnectedLinks(source).length === 0) { // jshint ignore:line
return {
sourceComponent: sourceComponent,
source: {
view: draggedView
},
target: {
view: targetUnderMouse
}
};
}
return {
sourceComponent: sourceComponent,
source: {
view: draggedView
}
};
}
validate(graph : dia.Graph, dsl: string, flo: Flo.EditorContext) : Promise<Map<string | number, Flo.Marker[]>> {
return new Promise((resolve, reject) => {
let allMarkers = new Map<string | number, Array<Flo.Marker>>();
graph.getElements().filter(e => e.get('metadata')).forEach(e => {
let markers : Array<Flo.Marker> = []
let group = e.get('metadata')?.group;
if (e.get('metadata')?.unresolved) {
markers.push({
severity: Flo.Severity.Error,
range: e.attr('range'),
message: `Unknown element '${e.get('metadata')?.name}` + (group ? ` from group '${e.get('metadata')?.group}'` : '')
});
} else if (group) {
let links = graph.getConnectedLinks(e);
let outgoingLinksNumber = links.filter(l => l.get('source').id === e.id).length;
let incomingLinksNumber = links.filter(l => l.get('target').id === e.id).length;
if (group === 'sink') {
if (outgoingLinksNumber > 0) {
markers.push({
severity: Flo.Severity.Error,
range: e.attr('range'),
message: `Sink node cannot have outgoing links`
});
}
if (incomingLinksNumber > 1) {
markers.push({
severity: Flo.Severity.Error,
range: e.attr('range'),
message: `Sink node cannot have more than one incoming link`
});
}
} else if (group === 'source') {
if (outgoingLinksNumber > 1) {
markers.push({
severity: Flo.Severity.Error,
range: e.attr('range'),
message: `Source node cannot have more than one outgoing link`
});
}
if (incomingLinksNumber > 0) {
markers.push({
severity: Flo.Severity.Error,
range: e.attr('range'),
message: `Sink node cannot have incoming links`
});
}
} else if (group === 'processor') {
if (outgoingLinksNumber > 1) {
markers.push({
severity: Flo.Severity.Error,
range: e.attr('range'),
message: `Processor node cannot have more than one outgoing link`
});
}
if (incomingLinksNumber > 1) {
markers.push({
severity: Flo.Severity.Error,
range: e.attr('range'),
message: `Processor node cannot have more than one incoming link`
});
}
} else {
markers.push({
severity: Flo.Severity.Error,
range: e.attr('range'),
message: `Unknown element '${e.get('metadata')?.name} from group '${e.get('metadata')?.group}'`
});
}
}
if (markers.length) {
allMarkers.set(e.id, markers);
}
});
resolve(allMarkers);
});
// var errors = [];
// var graph = flo.getGraph();
// var constraints = element.get('metadata')?.constraints);
// if (constraints) {
// var incoming = graph.getConnectedLinks(element, {inbound: true});
// var outgoing = graph.getConnectedLinks(element, {outbound: true});
// if (typeof constraints.maxIncomingLinksNumber === 'number' || typeof constraints.minIncomingLinksNumber === 'number') {
// if (typeof constraints.maxIncomingLinksNumber === 'number' && constraints.maxIncomingLinksNumber < incoming.length) {
// if (constraints.maxIncomingLinksNumber === 0) {
// errors.push({
// message: 'Sources must appear at the start of a stream',
// range: element.attr('range')
// });
// } else {
// errors.push({
// message: 'Max allowed number of incoming links is ' + constraints.maxIncomingLinksNumber,
// range: element.attr('range')
// });
// }
// }
// if (typeof constraints.minIncomingLinksNumber === 'number' && constraints.minIncomingLinksNumber > incoming.length) {
// errors.push({
// message: 'Min allowed number of incoming links is ' + constraints.minIncomingLinksNumber,
// range: element.attr('range')
// });
// }
// }
// if (typeof constraints.maxOutgoingLinksNumber === 'number' || typeof constraints.minOutgoingLinksNumber === 'number') {
// if (typeof constraints.maxOutgoingLinksNumber === 'number' && constraints.maxOutgoingLinksNumber < outgoing.length) {
// if (constraints.maxOutgoingLinksNumber === 0) {
// errors.push({
// message: 'Sinks must appear at the end of a stream',
// range: element.attr('range')
// });
// } else {
// errors.push({
// message: 'Max allowed number of outgoing links is ' + constraints.maxOutgoingLinksNumber,
// range: element.attr('range')
// });
// }
// }
// if (typeof constraints.minOutgoingLinksNumber === 'number' && constraints.minOutgoingLinksNumber > outgoing.length) {
// errors.push({
// message: 'Min allowed number of outgoing links is ' + constraints.minOutgoingLinksNumber,
// range: element.attr('range')
// });
// }
// }
// if (constraints.xorSourceSink && incoming.length && outgoing.length) {
// errors.push({
// message: 'Node can either have incoming or outgoing links, but not both',
// range: element.attr('range')
// });
// }
// }
// if (!element.get('metadata') || element.get('metadata').unresolved)) {
// var msg = 'Unknown element \'' + element.get('metadata')?.name + '\'';
// if (element.get('metadata')?.group) {
// msg += ' from group \'' + element.get('metadata')?.group + '\'.';
// }
// errors.push({
// message: msg,
// range: element.attr('range')
// });
// }
//
// // If possible, verify the properties specified match those allowed on this type of element
// // propertiesRanges are the ranges for each property included the entire '--name=value'.
// // The format of a range is {'start':{'ch':NNNN,'line':NNNN},'end':{'ch':NNNN,'line':NNNN}}
// var propertiesRanges = element.attr('propertiesranges');
// if (propertiesRanges) {
// var moduleSchema = element.get('metadata');
// // Grab the list of supported properties for this module type
// moduleSchema.get('properties').then(function(moduleSchemaProperties) {
// if (!moduleSchemaProperties) {
// moduleSchemaProperties = {};
// }
// // Example moduleSchemaProperties:
// // {"host":{"name":"host","type":"String","description":"the hostname of the mail server","defaultValue":"localhost","hidden":false},
// // "password":{"name":"password","type":"String","description":"the password to use to connect to the mail server ","defaultValue":null,"hidden":false}
// var specifiedProperties = element.attr('props');
// Object.keys(specifiedProperties).forEach(function(propertyName) {
// if (!moduleSchemaProperties[propertyName]) {
// // The schema does not mention that property
// var propertyRange = propertiesRanges[propertyName];
// if (propertyRange) {
// errors.push({
// message: 'unrecognized option \''+propertyName+'\' for module \''+element.get('metadata')?.name+'\'',
// range: propertyRange
// });
// }
// }
// });
// });
// }
//
// return errors;
}
moveNodeOnNode(context : Flo.EditorContext, node : dia.Element, pivotNode : dia.Element, side : string, shouldRepairDamage : boolean) {
// side = side || 'left';
// if (this.canSwap(context, node, pivotNode, side)) {
// let link : dia.Link;
// let i : number;
// if (side === 'left') {
// let sources : Array<string> = [];
// if (shouldRepairDamage) {
// /*
// * Commented out because it doesn't prevent cycles.
// */
// // if (graph.getConnectedLinks(pivotNode, {inbound: true}).length > 0 || graph.getConnectedLinks(node, {outbound: true}).length > 0) {
// this.repairDamage(context, node);
// // }
// }
// context.getGraph().getConnectedLinks(pivotNode, {inbound: true}).forEach(link => {
// sources.push(link.get('source').id);
// link.remove();
// });
// sources.forEach(id => {
// context.createLink({
// 'id': id,
// 'selector': '.output-port'
// }, {
// 'id': node.id,
// 'selector': '.input-port'
// });
// })
// for (i = 0; i < sources.length; i++) {
// flo.createLink({
// 'id': sources[i],
// 'selector': '.output-port'
// }, {
// 'id': node.id,
// 'selector': '.input-port'
// });
// }
// flo.createLink({
// 'id': node.id,
// 'selector': '.output-port'
// }, {
// 'id': pivotNode.id,
// 'selector': '.input-port'
// });
// } else if (side === 'right') {
// var targets = [];
// if (shouldRepairDamage) {
// /*
// * Commented out because it doesn't prevent cycles.
// */
// // if (graph.getConnectedLinks(pivotNode, {outbound: true}).length > 0 || graph.getConnectedLinks(node, {inbound: true}).length > 0) {
// repairDamage(flo, node);
// // }
// }
// var pivotSourceLinks = flo.getGraph().getConnectedLinks(pivotNode, {outbound: true});
// for (i = 0; i < pivotSourceLinks.length; i++) {
// link = pivotSourceLinks[i];
// targets.push(link.get('target').id);
// link.remove();
// }
// for (i = 0; i < targets.length; i++) {
// flo.createLink({
// 'id': node.id,
// 'selector': '.output-port'
// }, {
// 'id': targets[i],
// 'selector': '.input-port'
// });
// }
// flo.createLink({
// 'id': pivotNode.id,
// 'selector': '.output-port'
// }, {
// 'id': node.id,
// 'selector': '.input-port'
// });
// }
// }
}
/**
* Node moved onto a link. Remove the existing link and replace it with two links
* that go from the original link source to the dropped node and from the dropped node
* to the original link target.
*/
moveNodeOnLink(context : Flo.EditorContext, node : dia.Element, link : dia.Link, shouldRepairDamage : boolean) {
let source = link.get('source').id;
let target = link.get('target').id;
if (shouldRepairDamage) {
this.repairDamage(context, node);
}
link.remove();
if (source) {
let sourceView = context.getPaper().findViewByModel(context.getGraph().getCell(source));
let magnetS = Flo.findMagnetByClass(sourceView, '.output-port');
let targetView = context.getPaper().findViewByModel(node);
let magnetT = Flo.findMagnetByClass(targetView, '.input-port');
let sourceEnd : Flo.LinkEnd = {id: sourceView.model.id, selector: sourceView.getSelector(magnetS, null)};
if (magnetS.getAttribute('port')) {
sourceEnd.port = magnetS.getAttribute('port');
}
let targetEnd : Flo.LinkEnd = {id: targetView.model.id, selector: targetView.getSelector(magnetT, null)};
if (magnetT.getAttribute('port')) {
targetEnd.port = magnetT.getAttribute('port');
}
context.createLink(sourceEnd, targetEnd, null, null);
}
if (target) {
let sourceView = context.getPaper().findViewByModel(node);
let magnetS = Flo.findMagnetByClass(sourceView, '.output-port');
let targetView = context.getPaper().findViewByModel(context.getGraph().getCell(target));
let magnetT = Flo.findMagnetByClass(targetView, '.input-port');
let sourceEnd : Flo.LinkEnd = {id: sourceView.model.id, selector: sourceView.getSelector(magnetS, null)};
if (magnetS.getAttribute('port')) {
sourceEnd.port = magnetS.getAttribute('port');
}
let targetEnd : Flo.LinkEnd = {id: targetView.model.id, selector: targetView.getSelector(magnetT, null)};
if (magnetT.getAttribute('port')) {
targetEnd.port = magnetT.getAttribute('port');
}
context.createLink(sourceEnd, targetEnd, null, null);
}
}
/**
* When a node is removed any dangling links should be removed. What this function will also try to do
* is if removing a node from a chain it will attempt to replace dangling links with a link from the
* deleted nodes original source to the deleted nodes original target.
*/
repairDamage(context : Flo.EditorContext, node : dia.Element) {
// let sources : Array<string> = [];
// let targets : Array<string> = [];
// let i = 0;
// context.getGraph().getConnectedLinks(node).forEach(link => {
// let targetId = link.get('target').id;
// let sourceId = link.get('source').id;
// if (targetId === node.id) {
// link.remove();
// sources.push(sourceId);
// } else if (sourceId === node.id) {
// link.remove();
// targets.push(targetId);
// }
// });
// /*
// * If appropriate, create new links to replace the dangling ones deleted
// */
// if (sources.length === 1) {
// var source = sources[0];
// for (i = 0; i < targets.length; i++) {
// flo.createLink({'id': source,'selector': '.output-port'}, {'id': targets[i],'selector': '.input-port'});
// }
// } else if (targets.length === 1) {
// var target = targets[0];
// for (i = 0; i < sources.length; i++) {
// flo.createLink({'id': sources[i], 'selector': '.output-port'}, {'id': target,'selector': '.input-port'});
// }
// }
}
/**
* Check if node being dropped and drop target node next to each other such that they won't be swapped by the drop
*/
canSwap(context : Flo.EditorContext, dropee : dia.Element, target : dia.Element, side : string) : boolean {
let i, targetId, sourceId, noSwap = (dropee.id === target.id);
if (dropee === target) {
console.debug('What!??? Dragged == Dropped!!! id = ' + target);
}
let links = context.getGraph().getConnectedLinks(dropee);
for (i = 0; i < links.length && !noSwap; i++) {
targetId = links[i].get('target').id;
sourceId = links[i].get('source').id;
noSwap = (side === 'left' && targetId === target.id && sourceId === dropee.id) || (side === 'right' && targetId === dropee.id && sourceId === target.id);
}
return !noSwap;
}
}

View File

@@ -0,0 +1,186 @@
/*
* Copyright 2016 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { dia } from 'jointjs';
/**
* Convert a graph to a text representation.
*
* @author Alex Boyko
* @author Andy Clement
*/
class GraphToTextConverter {
// Graph
private g : dia.Graph;
// Number of Links left to visit
private numberOfLinksToVisit : number;
// Number of nodes left to visit
private numberOfNodesToVisit : number;
// Map of links left to visit indexed by id
private linksToVisit : Set<string>;
// Map of nodes left to visit indexed by id
private nodesToVisit : Set<string>;
// Map of nodes incoming non-visited links degrees index by node id
private nodesInDegrees : Map<string, number>;
constructor(graph : dia.Graph) {
this.numberOfLinksToVisit = 0;
this.numberOfNodesToVisit = 0;
this.linksToVisit = new Set<string>();
this.nodesToVisit = new Set<string>();
this.nodesInDegrees = new Map<string, number>();
this.g = graph;
graph.getElements().forEach((element : dia.Element) => {
if (element.get('metadata')?.name) {
this.nodesToVisit.add(element.get('id'));
let indegree = 0;
this.g.getConnectedLinks(element, {inbound: true}).forEach(link => {
if (link.get('source') && link.get('source').id && this.g.getCell(link.get('source').id) &&
this.g.getCell(link.get('source').id).get('metadata')?.name) {
this.linksToVisit.add(link.get('id'));
this.numberOfLinksToVisit++;
indegree++;
}
});
this.nodesInDegrees.set(element.get('id'), indegree);
this.numberOfNodesToVisit++;
}
});
}
// Priority:
// 1. find links whose source has no other links pointing at it
// 2. find links whose source has already been processed (not currently needed in sample DSL since
// can't create graphs like that due to metamodel constraints)
// 3. find remaining links
private nextLink() : dia.Link {
let indegree : number = Number.MAX_VALUE;
let currentBest : dia.Link;
for (let id of Array.from(this.linksToVisit)) {
let link = <dia.Link> this.g.getCell(id);
let source = this.g.getCell(link.get('source').id);
let currentInDegree = this.nodesInDegrees.get(source.get('id'));
if (currentInDegree === 0) {
this.visitLink(link);
return link;
} else if (indegree > currentInDegree) {
indegree = currentInDegree;
currentBest = link;
}
}
if (currentBest) {
this.visitLink(currentBest);
}
return currentBest;
}
private visitNode(n : dia.Element) : void {
this.nodesToVisit.delete(n.get('id'));
this.numberOfNodesToVisit--;
}
private visitLink(e : dia.Link) : void {
this.linksToVisit.delete(e.get('id'));
let id = e.get('target').id;
this.nodesInDegrees.set(id, this.nodesInDegrees.get(id) - 1);
this.numberOfLinksToVisit--;
}
/**
* Starts at a link and proceeds down a chain. Converts each node to
* text and then joins them with a ' > '.
*/
private chainToText(link : dia.Link) : string {
let text = '';
let source = <dia.Element> this.g.getCell(link.get('source').id);
text += this.nodeToText(source);
while (link) {
let target = <dia.Element> this.g.getCell(link.get('target').id);
text += ' > ';
text += this.nodeToText(target);
// Find next not visited link to follow
link = null;
let outgoingLinks = this.g.getConnectedLinks(target, {outbound: true});
for (let i = 0; i < outgoingLinks.length && !link; i++) {
if (this.linksToVisit.has(outgoingLinks[i].get('id'))) {
source = target;
link = outgoingLinks[i];
this.visitLink(link);
}
}
}
return text;
}
/**
* Very basic format. From a node to the text:
* "name --key=value --key=value"
*/
private nodeToText(element : dia.Element) {
let text = '';
let props = element.attr('props');
if (!element) {
return;
}
text += element.get('metadata')?.name;
if (props) {
Object.keys(props).forEach(propertyName => {
text += ' --' + propertyName + '=' + props[propertyName];
});
}
this.visitNode(element);
return text;
}
private appendChainText(text : string, chainText : string) {
if (chainText) {
if (text) {
text += '\n';
}
text += chainText;
}
return text;
}
public convert() : string {
let text = '';
let chainText : string;
let id : string;
while (this.numberOfLinksToVisit) {
chainText = this.chainToText(this.nextLink());
text = this.appendChainText(text, chainText);
}
// Visit all disconnected nodes
this.nodesToVisit.forEach(id => {
chainText = this.nodeToText(<dia.Element> this.g.getCell(id));
text = this.appendChainText(text, chainText);
});
return text;
}
}
export function convertGraphToText(g : dia.Graph) : string {
return new GraphToTextConverter(g).convert();
}

View File

@@ -0,0 +1,170 @@
import { Flo } from 'spring-flo';
import { convertGraphToText } from './graph-to-text';
import { convertTextToGraph } from './text-to-graph';
const metamodelData: Array<RawMetadata> = [{
name: 'http', group: 'source', description: 'Receive HTTP input',
properties: [
{id: 'port', name: 'port', defaultValue: '80', description: 'Port on which to listen', type: 'number'}
],
}, {
name: 'rabbit', group: 'source', description: 'Receives messages from RabbitMQ',
properties: [
{id: 'queue', name: 'queue', description: 'the queue(s) from which messages will be received'},
{id: 'time-unit', name: 'time-unit', description: 'Time unit for heart beat messages', type: 'enum', options: ['HOURS', 'MINUTES', 'SECONDS', 'MILIOSECONDS'], defaultValue: 'SECONDS'},
{id: 'heart-beat', name: 'heart-beat', description: 'Heart beat on/off', type: 'boolean', defaultValue: false},
{id: 'interval', name: 'interval', description: 'Time period being consecutive heart beat messages', type: 'number', defaultValue: 20},
{id: 'url', name: 'url', description: 'Service URL', type: 'url'},
{id: 'password', name: 'password', description: 'Password to login to service', type: 'password'},
{id: 'messages', name: 'messages', description: 'List of messages', type: 'list'},
{id: 'counts', name: 'counts', description: 'List of counts', type: 'list[number]'},
{id: 'language', name: 'language', description: 'Code snippet language', type: 'enum', options: ['javascript', 'ruby', 'java'], defaultValue: 'javascript'},
{id: 'snippet', name: 'snippet', description: 'Code snippet', type: 'code', defaultValue: ''},
{id: 'successes', name: 'successes', description: 'List of successes', type: 'list[boolean]'},
],
}, {
name: 'filewatch', group: 'source', description: 'Produce messages from the content of files created in a directory',
properties: [
{id: 'dir', name: 'dir', description: 'the absolute path to monitor for files'},
{id: 'withSubDir', name: 'withSubDir', description: 'With subdirectories', defaultValue: true, type: 'boolean'},
{id: 'type', name: 'type', description: 'Type of file watching', type: 'enum', options: ['tough', 'so-so', 'easy']}
],
}, {
// name: 'multi', group: 'processor', description: 'Custom element with multiple inputs and outputs',
// properties: [
// {id: 'inputChannel', name: 'input-channel', description: 'input channel name', type: 'string'}
// ],
// }, {
name: 'transform', group: 'processor', description: 'Apply an expression to modify incoming messages',
properties: [
{id: 'expression', name: 'expression', defaultValue: 'payload', description: 'SpEL expression to apply'}
],
}, {
name: 'filter', group: 'processor', description: 'Only allow messages through that pass the filter expression',
properties: [
{id: 'expression', name: 'expression', defaultValue: 'true', description: 'SpEL expression to use for filtering'}
],
}, {
name: 'filesave', group: 'sink', description: 'Writes messages to a file',
properties: [
{id: 'dir', name: 'dir', description: 'Absolute path to directory'},
{id: 'name', name: 'name', description: 'The name of the file to create'}
],
}, {
name: 'end', group: 'sink', description: 'Writes messages to a file',
properties: [
{id: 'dir', name: 'dir', description: 'Absolute path to directory'},
{id: 'name', name: 'name', description: 'The name of the file to create'}
],
}, {
name: 'null', group: 'sink', description: 'Writes messages to a file',
properties: [
{id: 'dir', name: 'dir', description: 'Absolute path to directory'},
{id: 'name', name: 'name', description: 'The name of the file to create'}
],
}, {
name: 'console', group: 'sink', description: 'Writes messages to a file',
properties: [
{id: 'dir', name: 'dir', description: 'Absolute path to directory'},
{id: 'name', name: 'name', description: 'The name of the file to create'}
],
}, {
name: 'hdfs', group: 'sink', description: 'Writes messages to a file',
properties: [
{id: 'dir', name: 'dir', description: 'Absolute path to directory'},
{id: 'name', name: 'name', description: 'The name of the file to create'}
],
}, {
name: 'jdbc', group: 'sink', description: 'Writes messages to a file',
properties: [
{id: 'dir', name: 'dir', description: 'Absolute path to directory'},
{id: 'name', name: 'name', description: 'The name of the file to create'}
],
}, {
name: 'ftp', group: 'sink', description: 'Send messages over FTP',
properties: [
{id: 'host', name: 'host', description: 'the host name for the FTP server'},
{id: 'port', name: 'port', description: 'The port for the FTP server', type: 'number'},
{id: 'remoteDir', name: 'remoteDir', description: 'The remote directory on the server'},
],
}];
export interface RawMetadata {
name: string;
group: string;
description: string;
properties: Array < Flo.PropertyMetadata >;
}
class Metadata implements Flo.ElementMetadata {
constructor(private rawData: RawMetadata) {
}
get name(): string {
return this.rawData.name;
}
get group(): string {
return this.rawData.group;
}
description(): Promise < string > {
return Promise.resolve(this.rawData.description);
}
get(property: String): Promise < Flo.PropertyMetadata > {
return Promise.resolve(this.rawData.properties.find(p => p.id === property));
}
properties() : Promise<Map<string,Flo.PropertyMetadata>> {
let propertiesMap = new Map<string, Flo.PropertyMetadata>();
this.rawData.properties.forEach(p => propertiesMap.set(p.id, p));
return Promise.resolve(propertiesMap);
}
}
export class Metamodel implements Flo.Metamodel {
private rawData: Array<RawMetadata>;
constructor() {
this.rawData = metamodelData;
}
textToGraph(flo: Flo.EditorContext, dsl : string): Promise<any> {
console.log('Text -> Graph');
return this.load().then(metamodel => {
convertTextToGraph(flo, metamodel, dsl);
flo.performLayout();
flo.fitToPage();
})
}
graphToText(flo: Flo.EditorContext): Promise<string> {
console.log('Graph -> Text');
return new Promise((resolve) => resolve(convertGraphToText(flo.getGraph())));
}
load(): Promise < Map < string, Map < string, Flo.ElementMetadata >>> {
let data: Map < string, Map < string, Flo.ElementMetadata >> = new Map < string, Map < string, Flo.ElementMetadata >>();
this.rawData
.map(rawData => new Metadata(rawData))
.forEach(metadata => {
if (!data.has(metadata.group)) {
data.set(metadata.group, new Map < string, Flo.ElementMetadata >());
}
data.get(metadata.group).set(metadata.name, metadata);
}
);
return Promise.resolve(data);
}
groups(): Array < string > {
let groups: Set < string > = new Set < string >();
this.rawData.forEach(metadata => groups.add(metadata.group));
return Array.from(groups);
}
}

View File

@@ -0,0 +1,89 @@
import {Injectable} from '@angular/core';
import {dia} from 'jointjs';
import {PropertiesDialogComponent} from './properties.dialog.component';
import {BsModalService} from 'ngx-bootstrap/modal';
import {Flo, Properties} from '../../flo';
import {Validators} from '@angular/forms';
@Injectable({
providedIn: 'root'
})
export class PropertiesEditorService {
constructor(private modalService: BsModalService) {}
openPropertiesDialog(cell: dia.Cell) {
const bsModalRef = this.modalService.show(PropertiesDialogComponent);
const metadata: Flo.ElementMetadata = cell.get('metadata');
bsModalRef.content.title = `Properties for ${metadata.name.toUpperCase()}`;
let propertiesModel = new SamplePropertiesGroupModel(new Properties.DefaultCellPropertiesSource(cell));
propertiesModel.load();
bsModalRef.content.propertiesGroupModel = propertiesModel;
}
}
class SamplePropertiesGroupModel extends Properties.PropertiesGroupModel {
protected createControlModel(property: Properties.Property): Properties.ControlModel<any> {
let inputType = Properties.InputType.TEXT;
let validation: Properties.Validation;
switch (property.type) {
case 'number':
inputType = Properties.InputType.NUMBER;
break;
case 'url':
inputType = Properties.InputType.URL;
break;
case 'password':
inputType = Properties.InputType.PASSWORD;
break;
case 'boolean':
return new Properties.CheckBoxControlModel(property);
case 'e-mail':
inputType = Properties.InputType.EMAIL;
break;
case 'list':
case 'list[number]':
case 'list[boolean]':
return new Properties.GenericListControlModel(property);
case 'enum':
if (Array.isArray(property.valueOptions)) {
return new Properties.SelectControlModel(property, Properties.InputType.SELECT, (<Array<string>> property.valueOptions).map(o => {
return {
name: o,
value: o === property.defaultValue ? undefined : o
}
}));
}
break;
case 'code':
return new Properties.CodeControlModelWithDynamicLanguageProperty(property, 'language', this, this.encodeTextToDSL, this.decodeTextFromDSL);
default:
if (property.name === 'name') {
validation = {
validator: Validators.required,
errorData: [
{ id: 'required', message: 'Name is required!' }
]
}
}
break;
}
return new Properties.GenericControlModel(property, inputType, validation);
}
encodeTextToDSL(text: string): string {
return '\"' + text.replace(/(?:\r\n|\r|\n)/g, '\\n').replace(/"/g, '""') + '\"';
}
decodeTextFromDSL(dsl: string): string {
if (dsl.charAt(0) === '\"' && dsl.charAt(dsl.length - 1) === '\"') {
dsl = dsl.substr(1, dsl.length - 2);
}
return dsl.replace(/\\n/g, '\n').replace(/\"\"/g, '"');
}
}

View File

@@ -0,0 +1,13 @@
<div class="modal-header">
<h4 class="modal-title pull-left">{{title}}</h4>
<button type="button" class="btn-close close pull-right" aria-label="Close" (click)="bsModalRef.hide()">
<span aria-hidden="true" class="visually-hidden">&times;</span>
</button>
</div>
<div class="modal-body">
<properties-group *ngIf="propertiesGroupModel" [propertiesGroupModel]="propertiesGroupModel" [form]="propertiesFormGroup"></properties-group>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" (click)="handleCancel()">Close</button>
<button type="button" class="btn btn-default" (click)="handleOk()" [disabled]="okDisabled">OK</button>
</div>

View File

@@ -0,0 +1,39 @@
import { Component, ViewEncapsulation } from '@angular/core';
import { BsModalRef } from 'ngx-bootstrap/modal';
import { Properties } from 'spring-flo';
import { FormGroup } from '@angular/forms';
// For JS linting
// import 'jshint';
@Component({
selector: 'properties-dialog-content',
templateUrl: './properties.dialog.component.html',
encapsulation: ViewEncapsulation.None
})
export class PropertiesDialogComponent {
public title: string;
propertiesGroupModel : Properties.PropertiesGroupModel;
propertiesFormGroup : FormGroup;
constructor(public bsModalRef: BsModalRef) {
this.propertiesFormGroup = new FormGroup({});
}
handleOk() {
this.propertiesGroupModel.applyChanges();
this.bsModalRef.hide();
}
handleCancel() {
this.bsModalRef.hide();
}
get okDisabled() {
return !this.propertiesGroupModel || !this.propertiesFormGroup || !this.propertiesFormGroup.valid;
}
}

View File

@@ -0,0 +1,449 @@
/*
* Copyright 2016 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Flo, Constants } from 'spring-flo';
import { dia } from 'jointjs';
const dagre = require('dagre');
const joint : any = Flo.joint;
const HANDLE_ICON_MAP = new Map<string, string>()
.set(Constants.REMOVE_HANDLE_TYPE, 'icons/delete.svg')
.set(Constants.PROPERTIES_HANDLE_TYPE, 'icons/cog.svg');
const DECORATION_ICON_MAP = new Map<string, string>()
.set(Constants.ERROR_DECORATION_KIND, 'icons/error.svg');
const IMAGE_W = 120;
const IMAGE_H = 40;
const PORT_RADIUS = 2;
joint.shapes.flo.Multi = joint.shapes.basic.Generic.extend({
markup:
'<g class="stream-module">' +
'<g class="shape">' +
'<rect class="box"/>' +
'<text class="label"/>' +
'<text class="label2"/>' +
'</g>' +
'<g class="port1-group">' +
'<circle class="input-port1" />' +
'<g class="label-group">' +
'<rect class="port1-label-rect"/>' +
'<text class="port1-label"></text>' +
'</g>' +
'</g>' +
'<g class="port2-group">' +
'<circle class="input-port2" />' +
'<g class="label-group">' +
'<rect class="port2-label-rect"/>' +
'<text class="port2-label"></text>' +
'</g>' +
'</g>' +
'<g class="port3-group">' +
'<circle class="input-port3" />' +
'<g class="label-group">' +
'<rect class="port3-label-rect"/>' +
'<text class="port3-label"></text>' +
'</g>' +
'</g>' +
'<g class="port4-group">' +
'<circle class="input-port4" />' +
'<g class="label-group">' +
'<rect class="port4-label-rect"/>' +
'<text class="port4-label"></text>' +
'</g>' +
'</g>' +
'<g class="port5-group">' +
'<circle class="input-port5" />' +
'<g class="label-group">' +
'<rect class="port5-label-rect"/>' +
'<text class="port5-label"></text>' +
'</g>' +
'</g>' +
'<rect class="output-port"/>' +
'</g>',
defaults: joint.util.defaultsDeep({
type: joint.shapes.flo.NODE_TYPE,
position: {x: 0, y: 0},
size: { width: IMAGE_W, height: IMAGE_H },
attrs: {
'.': {
magnet: false,
},
'.box': {
width: IMAGE_W,
height: IMAGE_H,
rx: 2,
ry: 2,
// 'fill-opacity':0, // see through
stroke: '#6db33f',
fill: '#eeeeee',
'stroke-width': 2,
},
'.input-port1': {
portName: 'port-1',
port: 'input',
ref: '.box',
refCx: 0,
refCy: 1 * 0.17,
r: PORT_RADIUS,
magnet: true,
class: 'input-port1 flo-input-port'
},
'.port1-label': {
ref: '.input-port1',
'ref-x': 10,
'ref-y': 0,
text: 'port-1',
class: 'port1-label flo-port-label'
},
'.port1-label-rect': {
ref: '.port1-label',
refWidth: 1,
refHeight: 1,
refX: 0,
refY: 0,
class: 'port1-label-rect flo-port-label-bg'
},
'.input-port2': {
portName: 'port-2',
port: 'in-port-2',
r: PORT_RADIUS,
ref: '.box',
refCx: 0,
refCy: 2 * 0.17,
magnet: true,
class: 'input-port2 flo-input-port'
},
'.port2-label': {
ref: '.input-port2',
'ref-x': 10,
'ref-y': 0,
text: 'port-2',
class: 'port2-label flo-port-label'
},
'.port2-label-rect': {
ref: '.port2-label',
refWidth: 1,
refHeight: 1,
refX: 0,
refY: 0,
class: 'port2-label-rect flo-port-label-bg'
},
'.input-port3': {
portName: 'port-3',
port: 'input',
r: PORT_RADIUS,
ref: '.box',
refCx: 0,
refCy: 3 * 0.17,
magnet: true,
class: 'input-port3 flo-input-port'
},
'.port3-label': {
ref: '.input-port3',
'ref-x': 10,
'ref-y': 0,
text: 'port-3',
class: 'port3-label flo-port-label'
},
'.port3-label-rect': {
ref: '.port3-label',
refWidth: 1,
refHeight: 1,
refX: 0,
refY: 0,
class: 'port3-label-rect flo-port-label-bg'
},
'.input-port4': {
portName: 'port-4',
port: 'input',
r: PORT_RADIUS,
ref: '.box',
refCx: 0,
refCy: 4 * 0.17,
magnet: true,
class: 'input-port4 flo-input-port'
},
'.port4-label': {
ref: '.input-port4',
'ref-x': 10,
'ref-y': 0,
text: 'port-4',
class: 'port4-label flo-port-label'
},
'.port4-label-rect': {
ref: '.port4-label',
refWidth: 1,
refHeight: 1,
refX: 0,
refY: 0,
class: 'port4-label-rect flo-port-label-bg'
},
'.input-port5': {
portName: 'port-5',
port: 'input',
r: PORT_RADIUS,
ref: '.box',
refCx: 0,
refCy: 5 * 0.17,
magnet: true,
class: 'input-port5 flo-input-port'
},
'.port5-label': {
ref: '.input-port5',
'ref-x': 10,
'ref-y': 0,
text: 'port-5',
class: 'port5-label flo-port-label'
},
'.port5-label-rect': {
ref: '.port5-label',
refWidth: 1,
refHeight: 1,
refX: 0,
refY: 0,
class: 'port5-label-rect flo-port-label-bg'
},
'.output-port': {
port: 'output',
height: 8, width: 8,
magnet: true,
fill: '#eeeeee',
transform: 'translate(' + (IMAGE_W - 4) + ',' + ((IMAGE_H / 2) - 4) + ')',
stroke: '#34302d',
'stroke-width': 1,
},
'.label': {
'ref-x': 0.5, // jointjs specific: relative position to ref'd element
'ref-y': 0.525,
'y-alignment': 'middle',
'x-alignment': 'middle',
ref: '.box', // jointjs specific: element for ref-x, ref-y
fill: 'black',
'font-size': 14
},
'.label2': {
'y-alignment': 'middle',
'ref-x': 0.15, // jointjs specific: relative position to ref'd element
'ref-y': 0.2, // jointjs specific: relative position to ref'd element
ref: '.box', // jointjs specific: element for ref-x, ref-y
fill: 'black',
'font-size': 20
},
'.shape': {
}
}
}, joint.shapes.basic.Generic.prototype.defaults)
});
/**
* @author Alex Boyko
* @author Andy Clement
*/
export class Renderer implements Flo.Renderer {
createHandle(kind :string) : dia.Element {
return new joint.shapes.flo.ErrorDecoration({
size: {width: 10, height: 10},
attrs: {
'image': {
'xlink:href': HANDLE_ICON_MAP.get(kind)
}
}
});
}
createDecoration(kind : string, parent: dia.Cell) : dia.Element {
const error = new joint.shapes.flo.ErrorDecoration({
size: {width: 16, height: 16},
attrs: {
'image': {
'xlink:href': DECORATION_ICON_MAP.get(kind)
}
}
});
if (parent instanceof joint.dia.Element) {
const pt = (<dia.Element> parent).getBBox().topRight().offset(-error.size().width, 0);
error.position(pt.x, pt.y);
} else {
// TODO: do something for the link perhaps?
}
return error;
}
createNode(viewerDescriptor: Flo.ViewerDescriptor, metadata : Flo.ElementMetadata, props : Map<string, any>): dia.Element {
if (metadata.name === 'multi') {
return new joint.shapes.flo.Multi();
}
return new joint.shapes.flo.Node();
}
initializeNewNode(node : dia.Element, viewerDescriptor : Flo.ViewerDescriptor) {
let metadata : Flo.ElementMetadata = node.get('metadata');
if (metadata) {
node.attr('.label/text', node.get('metadata').name);
let group = node.get('metadata')?.group;
if (group === 'source') {
node.attr('.input-port/display','none');
}
if (group === 'sink') {
node.attr('.output-port/display','none');
}
}
}
createLink(source : Flo.LinkEnd, target : Flo.LinkEnd, metadata : Flo.ElementMetadata, props : Map<string, any>) : dia.Link {
const link = new joint.shapes.flo.Link(joint.util.deepSupplement({
smooth: true,
attrs: {
'.': {
//filter: { name: 'dropShadow', args: { dx: 1, dy: 1, blur: 2 } }
},
'.connection': {'stroke-width': 3, 'stroke': 'black', 'stroke-linecap': 'round'},
'.marker-arrowheads': {display: 'none'},
'.tool-options': {display: 'none'}
},
}));
return link;
}
handleLinkEvent?(context: Flo.EditorContext, event: string, link: dia.Link) {
switch (event) {
case 'change:target':
this.updateTargetLabel(link, context.getPaper());
break;
}
}
updateTargetLabel(link: joint.dia.Link, paper: dia.Paper) {
const view = paper.findViewByModel(link.getTargetElement());
const portElement = view ? view.findBySelector(link.target().selector) : undefined;
const labelText = portElement && portElement.length ? portElement[0].getAttribute('port-name') : undefined;
setTimeout(() => {
let idx = -1;
for (let i = 0; idx < 0 && i < link.labels().length; i++) {
if (link.labels()[i].attrs.text.id === 'target-channel-label') {
idx = i;
}
}
if (idx >= 0) {
link.removeLabel(idx);
}
if (labelText) {
link.appendLabel({
attrs: {
text: {
id: 'target-channel-label',
text: labelText,
class: 'link-channel-label',
'text-anchor': 'end'
},
rect: {
class: 'link-channel-label'
}
},
position: {
args: {
keepGradient: true,
ensureLegibility: true
},
distance: -20,
offset: 20
},
});
}
});
}
isSemanticProperty(propertyPath : string, element : dia.Cell) : boolean {
return propertyPath === '.label/text';
}
refreshVisuals(cell : dia.Cell, propertyPath : string, paper : dia.Paper) : void {
// var type = element.attr('metadata/name');
}
layout(paper : dia.Paper) {
return new Promise((resolve) => {
let graph = paper.model;
let i : number;
let g = new dagre.graphlib.Graph();
g.setGraph({});
g.setDefaultEdgeLabel(() => {});
let nodes = graph.getElements();
nodes.forEach((node : dia.Element) => {
if (node.get('type') === joint.shapes.flo.NODE_TYPE) {
g.setNode(node.id, node.get('size'));
}
});
let links = graph.getLinks();
links.forEach((link : dia.Link) => {
if (link.get('type') === joint.shapes.flo.LINK_TYPE) {
let options = {
minlen: 1.5
};
// if (link.get('labels') && link.get('labels').length > 0) {
// options.minlen = 1 + link.get('labels').length * 0.5;
// }
g.setEdge(link.get('source').id, link.get('target').id, options);
link.set('vertices', []);
}
});
g.graph().rankdir = 'LR';
dagre.layout(g);
g.nodes().forEach((v : any) => {
let node : any = graph.getCell(v);
if (node) {
let bbox = node.getBBox();
node.translate(g.node(v).x - g.node(v).width / 2 - bbox.x, g.node(v).y - g.node(v).height / 2 - bbox.y);
}
});
resolve(undefined);
});
}
getLinkAnchorPoint(linkView : dia.LinkView, view : dia.ElementView, magnet : SVGElement, reference : dia.Point) : dia.Point {
if (magnet) {
let type = magnet.getAttribute('port');
let bbox = joint.V(magnet).bbox(false, (<any>linkView).paper.viewport);
let rect = joint.g.rect(bbox);
if (type === 'input') {
return joint.g.point(rect.x, rect.y + rect.height / 2);
} else {
return joint.g.point(rect.x + rect.width, rect.y + rect.height / 2);
}
} else {
return reference;
}
}
}

View File

@@ -0,0 +1,127 @@
/*
* Copyright 2016 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Flo } from 'spring-flo';
import { dia } from 'jointjs';
/**
* Convert a text representation to a graph.
*
* @author Alex Boyko
* @author Andy Clement
*/
class TextToGraphConverter {
constructor(private flo : Flo.EditorContext, private metamodel : Map<string, Map<string, Flo.ElementMetadata>>) {
}
matchGroup(name : string, incoming : number, outgoing : number) : string {
let score = Number.MIN_VALUE;
let group : string;
Array.from(this.metamodel.keys()).filter(group => this.metamodel.get(group).has(name)).map(group => this.metamodel.get(group).get(name)).find(match => {
let failedConstraintsNumber = 0;
if (match.group === 'source') {
if (incoming > 0) {
failedConstraintsNumber++;
}
if (outgoing > 1) {
failedConstraintsNumber++;
}
} else if (match.group === 'sink') {
if (incoming > 1) {
failedConstraintsNumber++;
}
if (outgoing > 0) {
failedConstraintsNumber++;
}
} else if (match.group === 'processor') {
if (incoming > 1) {
failedConstraintsNumber++;
}
if (outgoing > 1) {
failedConstraintsNumber++;
}
}
if (failedConstraintsNumber < score) {
score = failedConstraintsNumber;
group = match.group;
}
return failedConstraintsNumber === 0;
});
return group;
}
convertToGraph(input : string) {
this.flo.getGraph().clear();
// input is a string like this (3 nodes: foo, goo and hoo): foo --a=b --c=d > goo --d=e --f=g>hoo
let trimmed = input.trim();
if (trimmed.length===0) {
return;
}
trimmed.split('\n').forEach(line => {
let lastNode : dia.Element;
line.trim().split('>').map(e => e.trim()).forEach(element => {
let startOfProps = element.indexOf(' ');
let name = element.trim();
let properties = new Map<string, any>();
if (startOfProps !== -1) {
name = element.substring(0,startOfProps);
element.substring(startOfProps+1).trim().split(' ').map(pv => pv.trim()).filter(pv => pv.length).forEach(pv => {
var equalsIndex = pv.indexOf('=');
// The 2 skips the '--'
let key = pv.substring(2,equalsIndex);
let value = pv.substring(equalsIndex+1);
properties.set(key, value);
});
}
let group = this.matchGroup(name, 0, 0);
let newNode = this.flo.createNode(Flo.getMetadata(this.metamodel,name,group),properties,{x:0,y:0});
newNode.attr('.label/text',name);
if (lastNode) {
let sourceView = this.flo.getPaper().findViewByModel(lastNode);
let sourceMagnet = Flo.findMagnetByClass(sourceView, '.output-port');
let sourceEnd : Flo.LinkEnd = {
id: lastNode.id,
selector: sourceView.getSelector(sourceMagnet, undefined),
};
if (sourceMagnet.getAttribute('port')) {
sourceEnd.port = sourceMagnet.getAttribute('port');
}
let targetView = this.flo.getPaper().findViewByModel(newNode);
let targetMagnet = Flo.findMagnetByClass(targetView, '.input-port');
let targetEnd : Flo.LinkEnd = {
id: newNode.id,
selector: targetView.getSelector(targetMagnet, undefined),
};
if (targetMagnet.getAttribute('port')) {
targetEnd.port = targetMagnet.getAttribute('port');
}
this.flo.createLink(sourceEnd, targetEnd);
}
lastNode = newNode;
})
});
}
}
export function convertTextToGraph(flo : Flo.EditorContext, metamodel : Map<string, Map<string, Flo.ElementMetadata>>, input : string) {
return new TextToGraphConverter(flo, metamodel).convertToGraph(input);
}

View File

@@ -0,0 +1,3 @@
export const environment = {
production: true
};

View File

@@ -0,0 +1,16 @@
// This file can be replaced during build by using the `fileReplacements` array.
// `ng build` replaces `environment.ts` with `environment.prod.ts`.
// The list of file replacements can be found in `angular.json`.
export const environment = {
production: false
};
/*
* For easier debugging in development mode, you can import the following file
* to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`.
*
* This import should be commented out in production mode because it will have a negative impact
* on performance if an error is thrown.
*/
// import 'zone.js/plugins/zone-error'; // Included with Angular CLI.

BIN
projects/demo/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 15.0.2, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "https://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="512.002px" height="526.229px" viewBox="0 0 512.002 526.229" enable-background="new 0 0 512.002 526.229"
xml:space="preserve">
<path d="M451.419,288.771c1.053-8.415,1.85-16.972,1.85-25.656s-0.797-17.234-1.85-25.656l55.654-43.54
c5.011-3.95,6.449-11.055,3.16-16.843L457.611,85.91c-3.289-5.659-10.124-8.029-16.06-5.659l-65.509,26.44
c-13.554-10.394-28.405-19.207-44.465-25.913l-9.867-69.729C320.529,4.869,315.134,0,308.556,0H203.31
c-6.577,0-11.974,4.869-13.027,11.049l-9.866,69.729c-16.047,6.706-30.911,15.391-44.465,25.913l-65.516-26.44
c-5.916-2.235-12.757,0-16.046,5.659L1.767,177.075c-3.289,5.659-1.844,12.765,3.154,16.843l55.52,43.54
c-1.054,8.422-1.844,16.972-1.844,25.656s0.79,17.241,1.844,25.656l-55.52,43.54c-4.998,3.957-6.443,11.049-3.154,16.843
l52.623,91.165c3.289,5.666,10.13,8.029,16.046,5.666l65.516-26.44c13.554,10.381,28.418,19.194,44.465,25.9l9.866,69.735
c1.054,6.18,6.45,11.049,13.027,11.049h105.246c6.578,0,11.974-4.869,13.027-11.049l9.867-69.735
c16.046-6.706,30.91-15.391,44.464-25.9l65.509,26.44c5.923,2.235,12.771,0,16.06-5.666l52.623-91.165
c3.289-5.666,1.837-12.758-3.16-16.843L451.419,288.771z M255.933,355.204c-50.914,0-92.09-41.176-92.09-92.09
s41.176-92.09,92.09-92.09s92.09,41.176,92.09,92.09S306.847,355.204,255.933,355.204z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" class="collapse-handle" viewBox="0 0 24 24">
<line x1="4" y1="4" x2="20" y2="20" stroke="black" stroke-width="8" stroke-linecap="round"/>
<line x1="20" y1="4" x2="4" y2="20" stroke="black" stroke-width="8" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 296 B

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "https://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="27.963px" height="27.963px" viewBox="0 0 27.963 27.963" style="enable-background:new 0 0 27.963 27.963;"
xml:space="preserve">
<g>
<circle cx="13.9815" cy="13.9815" r="12.9815" stroke="black" stroke-width="1" fill="red" />
<polygon style="fill:white;" points="15.578,17.158 16.19,4.579 11.803,4.579 12.413,17.158 "/>
<path style="fill:white;" d="M13.997,18.546c-1.471,0-2.5,1.029-2.5,2.526c0,1.443,0.999,2.528,2.444,2.528h0.056
c1.499,0,2.469-1.085,2.469-2.528C16.44,19.575,15.467,18.546,13.997,18.546z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 792 B

26
projects/demo/index.html Normal file
View File

@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html>
<head>
<title>Angular QuickStart</title>
<base href="/">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" rel="stylesheet" crossorigin="anonymous" integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65">
<link rel="stylesheet" href="styles.css">
<!-- Polyfill(s) for older browsers -->
<!--<script src="node_modules/core-js/client/shim.min.js"></script>-->
<!--<script src="node_modules/zone.js/dist/zone.js"></script>-->
<!--<script src="node_modules/systemjs/dist/system.src.js"></script>-->
<!--<script src="systemjs.config.js"></script>-->
<!--<script>-->
<!--System.import('main.js').catch(function(err){ console.error(err); });-->
<!--</script>-->
</head>
<body>
<demo-app>Loading AppComponent content here ...</demo-app>
</body>
</html>

View File

@@ -0,0 +1,44 @@
// Karma configuration file, see link for more information
// https://karma-runner.github.io/1.0/config/configuration-file.html
module.exports = function (config) {
config.set({
basePath: '',
frameworks: ['jasmine', '@angular-devkit/build-angular'],
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'),
require('karma-coverage'),
require('@angular-devkit/build-angular/plugins/karma')
],
client: {
jasmine: {
// you can add configuration options for Jasmine here
// the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html
// for example, you can disable the random execution with `random: false`
// or set a specific seed with `seed: 4321`
},
clearContext: false // leave Jasmine Spec Runner output visible in browser
},
jasmineHtmlReporter: {
suppressAll: true // removes the duplicated traces
},
coverageReporter: {
dir: require('path').join(__dirname, '../../coverage/showcase'),
subdir: '.',
reporters: [
{ type: 'html' },
{ type: 'text-summary' }
]
},
reporters: ['progress', 'kjhtml'],
port: 9876,
colors: true,
logLevel: config.LOG_INFO,
autoWatch: true,
browsers: ['Chrome'],
singleRun: false,
restartOnFileChange: true
});
};

5
projects/demo/main.ts Normal file
View File

@@ -0,0 +1,5 @@
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app/app.module';
platformBrowserDynamic().bootstrapModule(AppModule);

View File

@@ -0,0 +1,57 @@
/***************************************************************************************************
* BROWSER POLYFILLS
*/
/** IE9, IE10 and IE11 requires all of the following polyfills. **/
// import 'core-js/es6/symbol';
// import 'core-js/es6/object';
// import 'core-js/es6/function';
// import 'core-js/es6/parse-int';
// import 'core-js/es6/parse-float';
// import 'core-js/es6/number';
// import 'core-js/es6/math';
// import 'core-js/es6/string';
// import 'core-js/es6/date';
// import 'core-js/es6/array';
// import 'core-js/es6/regexp';
// import 'core-js/es6/map';
// import 'core-js/es6/set';
/** IE10 and IE11 requires the following for NgClass support on SVG elements */
// import 'classlist.js'; // Run `npm install --save classlist.js`.
/** IE10 and IE11 requires the following to support `@angular/animation`. */
// import 'web-animations-js'; // Run `npm install --save web-animations-js`.
/** Evergreen browsers require these. **/
// import 'core-js/es6/reflect';
// import 'core-js/es7/reflect';
/** ALL Firefox browsers require the following to support `@angular/animation`. **/
// import 'web-animations-js'; // Run `npm install --save web-animations-js`.
/***************************************************************************************************
* Zone JS is required by Angular itself.
*/
import 'zone.js/dist/zone'; // Included with Angular CLI.
/***************************************************************************************************
* APPLICATION IMPORTS
*/
/**
* Date, currency, decimal and percent pipes.
* Needed for: All but Chrome, Firefox, Edge, IE11 and Safari 10
*/
// import 'intl'; // Run `npm install --save intl`.
/**
* Need to import at least one locale-data with intl.
*/
// import 'intl/locale-data/jsonp/en';

64
projects/demo/styles.css Normal file
View File

@@ -0,0 +1,64 @@
@import "./../../node_modules/jointjs/dist/joint.core.css";
@import "./../../node_modules/codemirror/lib/codemirror.css";
@import "./../../node_modules/codemirror/addon/hint/show-hint.css";
@import "./../../node_modules/codemirror/addon/lint/lint.css";
@import "./../../node_modules/codemirror/addon/scroll/simplescrollbars.css";
h1 {
color: #369;
font-family: Arial, Helvetica, sans-serif;
font-size: 250%;
}
.connecting .flo-input-port {
fill: #aa1111;
r: 4;
}
.connecting .label-group {
display: block;
}
.label-group {
display: none;
}
.flo-input-port {
fill: #eeeeee;
stroke: #34302d;
stroke-width: 1px;
}
.flo-port-label {
text-anchor: start;
fill: black;
}
.flo-port-label-bg {
fill: lightblue;
stroke: lightblue;
stroke-width: 7px;
rx: 3;
ry: 3;
refWidth: 1;
refHeight: 1;
}
.joint-link:hover .link-channel-label {
opacity: 1;
}
.link-channel-label {
opacity: 0;
}
.available-magnet {
stroke-width: 2px;
}
.joint-element.highlighted rect {
stroke: #34302d;
stroke-width: 3;
}

14
projects/demo/test.ts Normal file
View File

@@ -0,0 +1,14 @@
// This file is required by karma.conf.js and loads recursively all the .spec and framework files
import 'zone.js/testing';
import { getTestBed } from '@angular/core/testing';
import {
BrowserDynamicTestingModule,
platformBrowserDynamicTesting
} from '@angular/platform-browser-dynamic/testing';
// First, initialize the Angular testing environment.
getTestBed().initTestEnvironment(
BrowserDynamicTestingModule,
platformBrowserDynamicTesting(),
);

View File

@@ -0,0 +1,25 @@
{
"extends": "../../tsconfig.json",
"angularCompilerOptions": {
"paths": {
"spring-flo": [ "../spring-flo/index.ts" ]
}
},
"compilerOptions": {
"outDir": "../../out-tsc/app",
"declaration": false,
"emitDecoratorMetadata": true,
"paths": {
"spring-flo": [
"../spring-flo/index.ts"
]
}
},
"files": [
"main.ts",
"polyfills.ts"
],
"include": [
"**/*.d.ts"
]
}

View File

@@ -0,0 +1,24 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "../../out-tsc/app",
"declaration": false,
"emitDecoratorMetadata": true,
"paths": {
"spring-flo/*": [
"../../dist/spring-flo/*"
],
"spring-flo": [
"../../dist/spring-flo"
]
}
},
"files": [
"main.ts",
"polyfills.ts"
],
"include": [
"**/*.d.ts"
]
}

View File

@@ -0,0 +1,28 @@
{
"extends": "../../tsconfig.json",
"angularCompilerOptions": {
"paths": {
"spring-flo": [ "../../dist/spring-flo" ]
}
},
"compilerOptions": {
"outDir": "../../out-tsc/app",
"declaration": false,
"emitDecoratorMetadata": true,
"paths": {
"spring-flo/*": [
"../../dist/spring-flo/*"
],
"spring-flo": [
"../../dist/spring-flo"
]
}
},
"files": [
"main.ts",
"polyfills.ts"
],
"include": [
"**/*.d.ts"
]
}

1
projects/demo/typings.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/* Types go here */

View File

@@ -0,0 +1,8 @@
declare module 'codemirror-minified' {
import * as CodeMirror from '@types/codemirror'
export = CodeMirror;
export as namespace CodeMirror;
}

View File

@@ -0,0 +1,3 @@
<div class="code-editor-host">
<textarea id="code-editor-host"></textarea>
</div>

View File

@@ -0,0 +1,20 @@
.CodeMirror {
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-o-user-select: none;
user-select: none;
height: 100%;
}
.CodeMirror-hint {
max-width: 38em;
}
.CodeMirror-vertical-ruler-error {
background-color: rgba(188, 0, 0, 0.5);
}
.CodeMirror-vertical-ruler-warning {
background-color: rgba(255, 188, 0, 0.5);
}
.CodeMirror-lint-tooltip {
z-index: 2000;
}

View File

@@ -0,0 +1,228 @@
import { Component, Input, Output, ElementRef, EventEmitter, OnInit, ViewEncapsulation, forwardRef } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import * as CodeMirror from 'codemirror';
import $ from 'jquery';
// CodeMirror extensions
import 'codemirror/mode/meta';
import 'codemirror/addon/lint/lint';
import 'codemirror/addon/hint/show-hint';
// import 'codemirror/addon/mode/loadmode';
import 'codemirror/addon/edit/matchbrackets';
import 'codemirror/addon/edit/closebrackets';
import 'codemirror/addon/display/placeholder';
import 'codemirror/addon/scroll/annotatescrollbar';
import 'codemirror/addon/scroll/simplescrollbars';
// Lint support
// Unclear how to import this dynamically...
// import 'codemirror/addon/lint/javascript-lint';
// import 'codemirror/addon/lint/json-lint';
// import 'codemirror/addon/lint/yaml-lint';
// TODO: use dynamic import with JS7 in the future. CM autoLoad cannot load it properly - thinks its AMD
// Supported languages until dynamic loading
// import 'codemirror/mode/groovy/groovy';
// import 'codemirror/mode/javascript/javascript';
// import 'codemirror/mode/python/python';
// import 'codemirror/mode/ruby/ruby';
// import 'codemirror/mode/clike/clike';
// import 'codemirror/mode/yaml/yaml';
@Component({
selector: 'code-editor',
templateUrl: './code-editor.component.html',
styleUrls: ['./code-editor.component.scss', ],
encapsulation: ViewEncapsulation.None,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => CodeEditorComponent),
multi: true
}
]
})
export class CodeEditorComponent implements OnInit, ControlValueAccessor {
private doc: CodeMirror.EditorFromTextArea;
private _dsl = '';
private _lint: boolean | CodeMirror.LintOptions = false;
private _language: string;
private errorRuler: any;
private warningRuler: any;
private _onChangeHandler: (_: any) => void;
private _onTouchHandler: () => void;
@Input('line-numbers')
lineNumbers = false;
@Input('line-wrapping')
lineWrapping = false;
@Input('scrollbar-style')
scrollbarStyle: string;
@Input()
placeholder: string;
@Input('overview-ruler')
overviewRuler: boolean;
@Output()
dslChange = new EventEmitter<string>();
@Output()
focus = new EventEmitter<void>();
@Output()
blur = new EventEmitter<void>();
@Output()
editor = new EventEmitter<CodeMirror.EditorFromTextArea>();
private _dslChangedHandler = () => {
this._dsl = this.doc.getValue();
this.dslChange.emit(this._dsl);
if (this._onChangeHandler) {
this._onChangeHandler(this._dsl);
}
};
constructor(private element: ElementRef) {}
@Input()
set dsl(dsl: string) {
this._dsl = dsl;
if (this.doc && this._dsl !== this.doc.getValue()) {
let cursorPosition = (<any>this.doc).getCursor();
this.doc.setValue(this._dsl || '');
(<any>this.doc).setCursor(cursorPosition);
}
}
@Input()
set language(_language: string) {
if (this._language !== _language) {
this._language = _language;
this.loadEditorMode();
}
}
ngOnInit() {
let options: any = {
value: this._dsl || '',
gutters: ['CodeMirror-lint-markers'],
extraKeys: {'Ctrl-Space': 'autocomplete'},
lineNumbers: this.lineNumbers,
lineWrapping: this.lineWrapping,
matchBrackets: true,
autoCloseBrackets: true,
};
if (this.scrollbarStyle) {
(<any> options).scrollbarStyle = this.scrollbarStyle;
}
if (this._lint) {
options.lint = this._lint;
}
this.doc = CodeMirror.fromTextArea(<HTMLTextAreaElement>$('#code-editor-host', this.element.nativeElement)[0], options);
if (this.placeholder) {
this.doc.setOption('placeholder', this.placeholder);
}
// Turns out "value" in the option doesn't set it.
this.doc.setValue(this._dsl || '');
this.doc.on('change', this._dslChangedHandler);
this.doc.on('focus', () => {
this.focus.emit();
if (this._onTouchHandler) {
this._onTouchHandler();
}
});
this.doc.on('blur', () => this.blur.emit());
this.warningRuler = (<any>this.doc).annotateScrollbar('CodeMirror-vertical-ruler-warning');
this.errorRuler = (<any>this.doc).annotateScrollbar('CodeMirror-vertical-ruler-error');
this.loadEditorMode();
this.editor.emit(this.doc);
}
private loadEditorMode() {
// CodeMirror doc object must be initialized
if (!this.doc) {
return
}
const info = this._language ? (<any>CodeMirror).findModeByName(this._language) : undefined;
// Set proper editor mode
if (info) {
this.doc.setOption('mode', info.mime);
// (<any>CodeMirror).autoLoadMode(this.doc, info.mode);
} else {
this.doc.setOption('mode', 'text/plain');
}
// Set proper Lint mode
this.doc.setOption('lint', this.getLintOptions());
}
writeValue(obj: any): void {
this.dsl = obj;
}
registerOnChange(fn: (_: any) => void): void {
this._onChangeHandler = fn;
}
registerOnTouched(fn: () => void): void {
this._onTouchHandler = fn;
}
private getLintOptions(): boolean | any {
switch (this._language) {
case 'javascript':
case 'json':
case 'coffeescript':
case 'yaml':
return {
onUpdateLinting: (annotations: {}[]) => {
const warnings: any[] = [];
const errors: any[] = [];
if (this.overviewRuler) {
if (Array.isArray(annotations)) {
annotations.forEach((a: any) => {
if (a.to && a.from && a.from.line >= 0 && a.from.ch >= 0 && a.to.line >= a.from.line && a.from.ch >= 0) {
if (a.severity === 'error') {
errors.push(a);
} else if (a.severity === 'warning') {
warnings.push(a);
}
}
});
}
}
this.warningRuler.update(warnings);
this.errorRuler.update(errors);
}
}
}
return false;
}
}

View File

@@ -0,0 +1,144 @@
import {Directive, Input, Output, EventEmitter, Inject, ElementRef, OnInit, OnDestroy} from '@angular/core';
import {DOCUMENT} from '@angular/common'
import { fromEvent } from 'rxjs';
import { sampleTime } from 'rxjs/operators';
import { CompositeDisposable, Disposable } from 'ts-disposables';
import $ from 'jquery';
@Directive({
selector: '[resizer]',
host: {'(mousedown)': 'startDrag()'}
})
export class ResizerDirective implements OnInit, OnDestroy {
private dragInProgress = false;
private vertical = true;
private first: string;
private second: string;
private _size: number;
private _splitSize: number;
private _subscriptions = new CompositeDisposable();
@Input()
maxSplitSize: number;
@Output()
sizeChange = new EventEmitter<number>();
private mouseMoveHandler = (e: any) => {
if (this.dragInProgress) {
this.mousemove(e);
}
};
@Input()
set splitSize(splitSize: number) {
if (this.maxSplitSize && splitSize > this.maxSplitSize) {
splitSize = this.maxSplitSize;
}
if (this.vertical) {
// Handle vertical resizer
$(this.element.nativeElement).css({
left: splitSize + 'px'
});
$(this.element.nativeElement).parent().find(this.first).css({
width: splitSize + 'px'
});
$(this.element.nativeElement).parent().find(this.second).css({
left: (splitSize + this._size) + 'px'
});
} else {
// Handle horizontal resizer
$(this.element.nativeElement).css({
bottom: splitSize + 'px'
});
$(this.element.nativeElement).parent().find(this.first).css({
bottom: (splitSize + this._size) + 'px'
});
$(this.element.nativeElement).parent().find(this.second).css({
height: splitSize + 'px'
});
}
this._splitSize = splitSize;
// Update the local field
this.sizeChange.emit(splitSize);
}
@Input()
set resizerWidth(width: number) {
this._size = width;
this.vertical = true;
}
@Input()
set resizerHeight(height: number) {
this._size = height;
this.vertical = false;
}
@Input()
set resizerLeft(first: string) {
this.first = first;
}
@Input()
set resizerTop(first: string) {
this.first = first;
}
@Input()
set resizerRight(second: string) {
this.second = second;
}
@Input()
set resizerBottom(second: string) {
this.second = second;
}
constructor(private element: ElementRef, @Inject(DOCUMENT) private document: any) {
}
startDrag() {
this.dragInProgress = true;
}
private mousemove(event: any) {
let size: number;
if (this.vertical) { // Handle vertical resizer. Calculate new size relative to palette container DOM node
size = event.pageX - $(this.first).offset().left;
} else {
// Handle horizontal resizer Calculate new size relative to palette container DOM node
size = window.innerHeight - event.pageY - $(this.second).offset().top;
}
this.splitSize = size;
}
ngOnInit() {
// Need to set left and right elements width and fire events on init when DOM is built
this.splitSize = this._splitSize;
let subscription1 = fromEvent($(this.document).get(0), 'mousemove')
.pipe(sampleTime(300))
.subscribe(this.mouseMoveHandler);
this._subscriptions.add(Disposable.create(() => subscription1.unsubscribe()));
let subscription2 = fromEvent($(this.document).get(0), 'mouseup')
.subscribe(e => {
if (this.dragInProgress) {
this.mousemove(e);
this.dragInProgress = false;
}
});
this._subscriptions.add(Disposable.create(() => subscription2.unsubscribe()));
}
ngOnDestroy() {
this._subscriptions.dispose();
}
}

View File

@@ -0,0 +1 @@
<textarea id="dsl-editor-host"></textarea>

View File

@@ -0,0 +1,22 @@
/* Code Mirror related styles START */
.CodeMirror {
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-o-user-select: none;
user-select: none;
height: 100%;
}
.CodeMirror-hint {
max-width: 38em;
}
.CodeMirror-vertical-ruler-error {
background-color: rgba(188, 0, 0, 0.5);
}
.CodeMirror-vertical-ruler-warning {
background-color: rgba(255, 188, 0, 0.5);
}
/* Code Mirror related styles END */

View File

@@ -0,0 +1,133 @@
import { Component, Input, Output, ElementRef, EventEmitter, OnInit, ViewEncapsulation } from '@angular/core';
import debounce from 'lodash/debounce';
import * as CodeMirror from 'codemirror';
import $ from 'jquery';
import 'codemirror/addon/lint/lint';
import 'codemirror/addon/hint/show-hint';
import 'codemirror/addon/display/placeholder';
import 'codemirror/addon/scroll/annotatescrollbar';
import 'codemirror/addon/scroll/simplescrollbars';
@Component({
selector: 'dsl-editor',
templateUrl: './dsl-editor.component.html',
styleUrls: ['./dsl-editor.component.scss', ],
encapsulation: ViewEncapsulation.None
})
export class DslEditorComponent implements OnInit {
private doc: CodeMirror.EditorFromTextArea;
private _dsl = '';
private _lint: boolean | CodeMirror.LintOptions = false;
private _hint: any;
@Input('line-numbers')
lineNumbers = false;
@Input('line-wrapping')
lineWrapping = false;
@Input('scrollbar-style')
scrollbarStyle: string;
@Input()
placeholder: string;
@Input()
debounce = 0;
@Output()
dslChange = new EventEmitter<string>();
@Output()
focus = new EventEmitter<void>();
@Output()
blur = new EventEmitter<void>();
@Output()
editor = new EventEmitter<CodeMirror.EditorFromTextArea>();
private _dslChangedHandler = () => {
this._dsl = this.doc.getValue();
this.dslChange.emit(this._dsl);
};
constructor(private element: ElementRef) {}
@Input()
set dsl(dsl: string) {
this._dsl = dsl;
if (this.doc && this._dsl !== this.doc.getValue()) {
let cursorPosition = (<any>this.doc).getCursor();
this.doc.setValue(this._dsl || '');
(<any>this.doc).setCursor(cursorPosition);
}
}
@Input()
set lintOptions(lintOptions: boolean | CodeMirror.LintOptions) {
this._lint = lintOptions;
if (this.doc) {
this.doc.setOption('lint', this._lint);
}
}
@Input()
set hintOptions(hintOptions: any) {
this._hint = hintOptions;
if (this.doc) {
this.doc.setOption('hintOptions', this._hint);
}
}
triggerLinting() {
this.doc.setOption('lint', {});
this.doc.setOption('lint', this._lint);
}
ngOnInit() {
let options: CodeMirror.EditorConfiguration = {
value: this._dsl || '',
gutters: ['CodeMirror-lint-markers'],
extraKeys: {'Ctrl-Space': 'autocomplete'},
lineNumbers: this.lineNumbers,
lineWrapping: this.lineWrapping,
electricChars: false,
smartIndent: false,
};
if (this.scrollbarStyle) {
(<any> options).scrollbarStyle = this.scrollbarStyle;
}
if (this._lint) {
options.lint = this._lint;
}
if (this._hint) {
(<any>options).hintOptions = this._hint;
}
this.doc = CodeMirror.fromTextArea(<HTMLTextAreaElement>$('#dsl-editor-host', this.element.nativeElement)[0], options);
if (this.placeholder) {
this.doc.setOption('placeholder', this.placeholder);
}
// Turns out "value" in the option doesn't set it.
this.doc.setValue(this._dsl || '');
this.doc.on('change', this.debounce ? debounce(this._dslChangedHandler, this.debounce) : this._dslChangedHandler);
this.doc.on('focus', () => this.focus.emit());
this.doc.on('blur', () => this.blur.emit());
this.editor.emit(this.doc);
}
}

View File

@@ -0,0 +1,135 @@
import { dia } from 'jointjs';
import { Flo } from '../shared/flo-common';
import * as _ from 'lodash';
const joint: any = Flo.joint;
import * as _$ from 'jquery';
const $: any = _$;
export class Utils {
static fanRoute(graph: dia.Graph, cell: dia.Cell) {
if (cell instanceof joint.dia.Element) {
const links = graph.getConnectedLinks(cell);
const groupsOfOverlappingLinks = _.groupBy(links, (link: dia.Link) => {
// the key of the group is the model id of the link's source or target, but not our cell id.
const sourceId = link.get('source').id;
const targetId = link.get('target').id;
return cell.id !== sourceId ? sourceId : targetId;
});
_.each(groupsOfOverlappingLinks, (group: any, key: string) => {
// If the member of the group has both source and target model adjust vertices.
let toRoute: any = {};
if (key !== undefined) {
group.forEach((link: dia.Link) => {
if (link.get('source').id === cell.get('id') && link.get('target').id) {
toRoute[link.get('target').id] = link;
} else if (link.get('target').id === cell.get('id') && link.get('source').id) {
toRoute[link.get('source').id] = link;
}
});
Object.keys(toRoute).forEach(k => {
Utils.fanRoute(graph, toRoute[k]);
});
}
});
} else if (cell instanceof joint.dia.Link) {
const link = <joint.dia.Link> cell;
// The cell is a link. Let's find its source and target models.
let src = link.source() || link.previous('source');
let trg = link.target() || cell.previous('target');
// If one of the ends is not a model, the link has no siblings.
if (!src.id || !trg.id) { return; }
const siblings: dia.Link[] = graph.getLinks().filter((sibling: dia.Link) => {
const _src = sibling.source();
const _trg = sibling.target();
const vertices = sibling.get('vertices');
const fanRouted = !vertices || vertices.length === 0 || sibling.get('fanRouted');
return ((_.isEqual(_src, src) && _.isEqual(_trg, trg)) || (_.isEqual(_src, trg) && _.isEqual(_trg, src))) && fanRouted;
});
switch (siblings.length) {
case 0:
// The link was removed and had no siblings.
break;
case 1:
// There is only one link between the source and target. No vertices needed.
let vertices = cell.get('vertices');
if (vertices && vertices.length && cell.get('fanRouted')) {
cell.unset('vertices');
}
break;
default:
// There is more than one siblings. We need to create vertices.
// First of all we'll find the middle point of the link.
let source = link.getSourceElement();
let target = link.getTargetElement();
if (!source || !target) {
// When clearing the graph it may happen that some nodes are gone and some are left
return;
}
let srcCenter = source.getBBox().center();
let trgCenter = target.getBBox().center();
let midPoint = joint.g.line(srcCenter, trgCenter).midpoint();
// Then find the angle it forms.
let theta = srcCenter.theta(trgCenter);
// This is the maximum distance between links
let gap = 20;
siblings.forEach((sibling: dia.Link, index: number) => {
// We want the offset values to be calculated as follows 0, 20, 20, 40, 40, 60, 60 ..
let offset = gap * Math.ceil(index / 2);
// Now we need the vertices to be placed at points which are 'offset' pixels distant
// from the first link and forms a perpendicular angle to it. And as index goes up
// alternate left and right.
//
// ^ odd indexes
// |
// |----> index 0 line (straight line between a source center and a target center.
// |
// v even indexes
let sign = index % 2 ? 1 : -1;
let angle = joint.g.toRad(theta + sign * 90);
// We found the vertex.
let vertex = joint.g.point.fromPolar(offset, angle, midPoint);
sibling.set('fanRouted', true);
(<any>sibling).set('vertices', [{ x: vertex.x, y: vertex.y }], {'fanRouted': true});
});
}
}
}
static isCustomPaperEvent(args: any): boolean {
return args.length === 5 &&
_.isString(args[0]) &&
(args[0].indexOf('link:') === 0 || args[0].indexOf('element:') === 0) &&
args[1] instanceof $.Event &&
args[2] instanceof joint.dia.CellView &&
_.isNumber(args[3]) &&
_.isNumber(args[4]);
}
}

View File

@@ -0,0 +1,55 @@
<ng-content select="[header]"></ng-content>
<div id="flow-view" class="flow-view" style="position:relative">
<div id="canvas" class="canvas" style="position:relative; display: block; width: 100%; height: 100%;">
<div *ngIf="!noPalette" id="palette-container" class="palette-container" style="overflow:hidden;">
<flo-palette [metamodel]="metamodel" [renderer]="renderer" [paletteSize]="paletteSize"
[paletteEntryPadding]="paletteEntryPadding || {width: 12, height: 12}"
(onPaletteEntryDrop)="handleDnDFromPalette($event)"
(paletteReady)="updatePaletteReadyState($event)"
(paletteFocus)="graphToTextSync=true"
[searchFilterPlaceHolder]="searchFilterPlaceHolder">
</flo-palette>
</div>
<div id="sidebar-resizer" *ngIf="!noPalette"
resizer
[splitSize]="paletteSize"
(sizeChange)="paletteSize = $event"
[resizerWidth]="6"
[resizerLeft]="'#palette-container'"
[resizerRight]="'#paper-container'">
</div>
<flo-editor-paper tabindex="0" (onDelete)="deleteSelected()" (onBlur)="selection = null"
(onProperties)="onPropertiesHandle()">
<div id="paper" class="paper" tabindex="0" style="overflow: hidden; position: absolute; display: block; height:100%; width:100%; overflow:auto;"></div>
<ng-content select="[canvas]"></ng-content>
</flo-editor-paper>
<span class="canvas-controls-container" ng-if="canvasControls">
<table ng-if="canvasControls.zoom" class="canvas-control zoom-canvas-control">
<tbody>
<tr>
<td>
<input class="zoom-canvas-input canvas-control zoom-canvas-control" type="text"
data-inline="true" [(ngModel)]="zoomPercent"
size="3">
</td>
<td>
<label class="canvas-control zoom-canvas-label">%</label>
</td>
<td>
<input type="range" data-inline="true" [(ngModel)]="zoomPercent"
[step]="zoomStep"
[max]="maxZoom" [min]="minZoom" data-type="range"
name="range" class="canvas-control zoom-canvas-control">
</td>
</tr>
</tbody>
</table>
</span>
</div>
</div>
<ng-content select="[footer]"></ng-content>

View File

@@ -0,0 +1,225 @@
flo-view {
width:100%;
height:100%;
margin: 0;
background-color: #eeeeee;
font-family: "Varela Round",sans-serif;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-o-user-select: none;
user-select: none;
}
.canvas {
border: 1px solid;
border-color: #6db33f;
border-radius: 2px;
margin-top: 3px;
}
/* Canvas contains the palette on the left and the paper on the right */
.paper {
padding: 0px;
background-color: #ffffff;
/* height: 100%;
width: 100%;
position: relative;
overflow: hidden;
*//* margin-left: 400px; */
}
#sidebar-resizer {
background-color: #34302d;
position: absolute;
top: 0;
bottom: 0;
width: 6px;
cursor: e-resize;
}
#palette-container {
background-color: #EEE;
position: absolute;
top: 0;
bottom: 0;
left: 0;
overflow: auto;
}
#paper-container {
position: absolute;
top: 0;
bottom: 0;
right: 0;
overflow: auto;
color: #FFF;
background-color: #FFF;
outline: none;
}
/* Tooltip START */
.node-tooltip .tooltip-description {
margin-top: 5px;
margin-left: 0px;
margin-bottom: 5px;
}
.node-tooltip {
display:none;
position:absolute;
border:1px solid #333;
background-color:#34302d;/*#161616;*/
border-radius:5px;
padding:5px;
color:#fff;
/* font-size:12px Arial;*/
font-family: "Varela Round",sans-serif;
font-size: 19px;
z-index: 100;
}
.tooltip-title-type {
font-size: 24px;
font-weight: bold;
}
.tooltip-title-group {
padding-left: 5px;
font-size: 20px;
font-style: italic;
}
.node-tooltip-option-name {
font-family: monospace;/*"Varela Round",sans-serif;*/
font-size: 17px;
font-weight: bold;
padding-right: 20px;
}
.node-tooltip-option-description {
font-family: "Varela Round",sans-serif;
font-size: 18px;
}
/* Tooltip END */
/* Validation Error Marker on Canvas START */
.error-tooltip p {
margin-top: 5px;
margin-left: 0px;
margin-bottom: 5px;
color:#fff;
}
.error-tooltip {
display:none;
position:absolute;
border:1px solid #333;
background-color:red;/*#161616;*/
border-radius:5px;
padding:5px;
color:#fff;
/* font-size:12px Arial;*/
font-family: "Varela Round",sans-serif;
font-size: 20px;
z-index: 100;
}
/* Validation Error Marker on Canvas END */
/* Controls on Canvas START */
.canvas-controls-container {
position: absolute;
right: 15px;
top: 5px;
}
.canvas-control {
background: transparent;
font-family: "Varela Round",sans-serif;
font-size: 11px;
vertical-align: middle;
margin: 0px;
}
.zoom-canvas-control {
border: 0px;
padding: 0px;
margin: 0px;
outline: none;
}
.zoom-canvas-input {
text-align: right;
font-weight:bold;
color: black;
background-color: transparent;
}
.zoom-canvas-label {
padding-right: 4px;
color: black;
}
/* Controls on Canvas END */
/* START - FLO CANVAS STYLES - override joint js styles */
.highlighted {
outline: none;
}
.joint-type-handle {
cursor: pointer;
}
.available-magnet {
stroke-width: 3;
}
.link {
fill: none;
stroke: #ccc;
stroke-width: 1.5px;
}
.link-tools .tool-options {
display: none; /* by default, we don't display link options tool */
}
/* Make transparent the circle around the link-tools (cog) icon. It'll allow shape to have a circle clicking area */
.link-tools .tool-options circle {
fill: transparent;
stroke: transparent;
}
.link-tools .tool-options path {
fill: black;
stroke: black;
}
.link-tools .tool-remove circle {
fill: red;
stroke: red;
}
.link-tools .tool-remove path {
fill: white;
stroke: white;
}
.link-tools-container {
stroke-width: 0;
fill: transparent;
}
/* END - FLO CANVAS STYLES */

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,64 @@
import {
Component,
ElementRef, EventEmitter,
HostListener, OnInit,
Output,
ViewChild,
ViewEncapsulation
} from '@angular/core';
@Component({
selector: 'flo-editor-paper',
template: `
<div #paper tabindex="0" id="paper-container">
<ng-content></ng-content>
</div>`,
styleUrls: ['./editor.component.scss'],
encapsulation: ViewEncapsulation.None
})
export class PaperComponent implements OnInit {
@ViewChild('paper', { static: true }) paperElement: ElementRef;
@Output()
onDelete = new EventEmitter<number>();
@Output()
onProperties = new EventEmitter<number>();
@Output()
onBlur = new EventEmitter<boolean>();
@HostListener('click')
click() {
this.paperElement.nativeElement.focus();
}
@HostListener('mousedown')
mousedown() {
this.paperElement.nativeElement.focus();
}
@HostListener('keydown.backspace')
backspaceHandle() {
this.onDelete.emit();
}
@HostListener('keydown.delete')
deleteHandle() {
this.onDelete.emit();
}
@HostListener('keydown.o')
oHandle() {
this.onProperties.emit();
}
ngOnInit(): void {
const onBlur = this.onBlur;
this.paperElement.nativeElement.addEventListener('blur', () => {
onBlur.emit();
});
}
}

12
projects/flo/index.ts Normal file
View File

@@ -0,0 +1,12 @@
export { FloModule } from './module';
export { Palette } from './palette/palette.component';
export { EditorComponent } from './editor/editor.component';
export { DslEditorComponent } from './dsl-editor/dsl-editor.component';
export { CodeEditorComponent } from './code-editor/code-editor.component';
export { PropertiesGroupComponent } from './properties/properties.group.component';
export { DynamicFormPropertyComponent } from './properties/df.property.component';
export { ResizerDirective } from './directives/resizer';
export * from './shared/flo-common';
export * from './shared/flo-properties';
export * from './shared/shapes';

37
projects/flo/module.ts Normal file
View File

@@ -0,0 +1,37 @@
import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { CommonModule } from '@angular/common';
import { Palette } from './palette/palette.component';
import { EditorComponent } from './editor/editor.component';
import { ResizerDirective } from './directives/resizer';
import { DslEditorComponent } from './dsl-editor/dsl-editor.component';
import { CodeEditorComponent } from './code-editor/code-editor.component';
import { PropertiesGroupComponent } from './properties/properties.group.component';
import { DynamicFormPropertyComponent } from './properties/df.property.component';
import { PaperComponent } from './editor/paper.component';
@NgModule({
imports: [
FormsModule,
CommonModule,
ReactiveFormsModule
],
declarations: [
Palette,
EditorComponent,
ResizerDirective,
DslEditorComponent,
CodeEditorComponent,
PropertiesGroupComponent,
DynamicFormPropertyComponent,
PaperComponent
],
exports: [
EditorComponent,
DslEditorComponent,
DynamicFormPropertyComponent,
PropertiesGroupComponent
]
})
export class FloModule { }

View File

@@ -0,0 +1,15 @@
{
"$schema": "../../../../node_modules/ng-packagr/ng-package.schema.json",
"dest": "../../dist/flo",
"deleteDestPath": true,
"lib": {
"entryFile": "./index.ts"
},
"allowedNonPeerDependencies": [
"codemirror",
"jointjs",
"jquery",
"lodash",
"ts-disposables"
]
}

24
projects/flo/package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "spring-flo",
"version": "0.13.0-alpha.0",
"description": "Library for quickly building text DSL visualization diagram editor",
"author": "Andy Clement, Alex Boyko",
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "https://github.com/spring-projects/spring-flo.git"
},
"dependencies": {
"codemirror": "5.65.12",
"jointjs": "3.4.1",
"jquery": "^3.6.3",
"lodash": "^4.17.21",
"ts-disposables": "2.2.3"
},
"peerDependencies": {
"@angular/core": ">=15.0.0 <17.0.0",
"@angular/forms": ">=15.0.0 < 17.0.0",
"@angular/common": ">=15.0.0 <17.0.0",
"rxjs": ">=7.0.0 <8.0.0"
}
}

View File

@@ -0,0 +1,9 @@
<div id="palette-filter" class="palette-filter">
<label class="palette-filter-label" for="palette-filter-textfield">
<input type="search" id="palette-filter-textfield" class="palette-filter-textfield" [(ngModel)]="filterText" (focus)="onFocus()" [placeholder]="searchFilterPlaceHolder"/>
</label>
</div>
<div id="palette-paper-container">
<div id="palette-paper" class="palette-paper" style="overflow:hidden;"></div>
</div>

View File

@@ -0,0 +1,67 @@
/* Joint JS paper for drawing palette -> canvas DnD visual feedback START */
#palette-floater {
/* TODO size relative to paper that goes on it? */
opacity: 0.75;
background-color: transparent;
/*
background-color: #6db33f;
*/
float:left;
position: absolute;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-o-user-select: none;
user-select: none;
}
#palette-floater.joint-paper > svg {
background-color: transparent;
}
#palette-paper-container {
overflow-y: auto;
overflow-x: hidden;
background-color: white;
color: white;
height:calc(100% - 46px);
width:100%;
}
/* Joint JS paper for drawing palette -> canvas DnD visual feedback END */
/* Palette START */
.palette-filter {
border: 3px solid #6db33f;
font-size:24px;
background-color: white;
}
.palette-filter-textfield {
width: 100%;
border: none;
/* border: 3px solid #6db33f;
*/ font-family: "Varela Round",sans-serif;
/* padding: 2px; */
}
.palette-paper {
background-color: #eeeeee;
/*
border-right: 7px solid;
*/
border-color: #6db33f;
/* width: 170px;
height:100%;
float: left;
*/
}
.no-matches-label {
fill: #777;
font-size: 14px;
}
/* Palette END */

View File

@@ -0,0 +1,676 @@
import {Component, ElementRef, Input, Output, EventEmitter, OnInit, OnDestroy, Inject, ViewEncapsulation} from '@angular/core';
import { Subject, fromEvent } from 'rxjs';
import { debounceTime } from 'rxjs/operators';
import { dia } from 'jointjs';
import { Flo } from '../shared/flo-common';
import '../shared/shapes';
import { Shapes, Constants } from '../shared/shapes';
import { DOCUMENT } from '@angular/common'
import $ from 'jquery';
import {Logger} from '../shared/logger';
const joint: any = Flo.joint;
const DEBOUNCE_TIME = 300;
@Component({
selector: 'flo-palette',
templateUrl: './palette.component.html',
styleUrls: ['./palette.component.scss'],
encapsulation: ViewEncapsulation.None
})
export class Palette implements OnInit, OnDestroy {
private _metamodelListener: Flo.MetamodelListener = {
metadataError: (data) => {},
metadataAboutToChange: () => {},
metadataChanged: () => {
if (this.initialized && this.metamodel) {
this.metamodel.load().then(metamodel => this.buildPalette(metamodel));
}
}
};
/**
* The names of any groups in the palette that have been deliberately closed (the arrow clicked on)
*/
private closedGroups: Set<string>;
/**
* Model of the clicked element
*/
private clickedElement: dia.Cell | undefined;
private viewBeingDragged: dia.CellView | undefined;
private initialized = false;
private _paletteSize: number;
private _filterText = '';
private paletteGraph: dia.Graph;
private palette: dia.Paper;
private floaterpaper: dia.Paper;
private filterTextModel = new Subject<string>();
private noMacthesFoundNode: dia.Cell;
@Input()
metamodel: Flo.Metamodel;
@Input()
renderer: Flo.Renderer;
@Input()
paletteEntryPadding: dia.Size = {width: 12, height: 12};
@Input()
searchFilterPlaceHolder = 'Search...';
@Output()
onPaletteEntryDrop = new EventEmitter<Flo.DnDEvent>();
@Output()
paletteReady = new EventEmitter<boolean>();
@Output()
paletteFocus = new EventEmitter<void>();
private mouseMoveHanlder = (e: any) => this.handleDrag(e);
private mouseUpHanlder = (e: any) => this.handleMouseUp(e);
@Input()
set paletteSize(size: number) {
Logger.debug('Palette Size: ' + size);
if (this._paletteSize !== size) {
this._paletteSize = size;
if (this.palette) {
this.layout();
}
}
}
constructor(private element: ElementRef, @Inject(DOCUMENT) private document: any) {
this.paletteGraph = new joint.dia.Graph();
this.paletteGraph.set('type', Constants.PALETTE_CONTEXT);
this._filterText = '';
this.closedGroups = new Set<string>();
}
onFocus(): void {
this.paletteFocus.emit();
}
ngOnInit() {
let element = $('#palette-paper', this.element.nativeElement);
// Create the paper for the palette using the specified element view
this.palette = new joint.dia.Paper({
el: element,
gridSize: 1,
model: this.paletteGraph,
height: $(this.element.nativeElement.parentNode).height(),
width: $(this.element.nativeElement.parentNode).width(),
elementView: this.getPaletteView(this.renderer && this.renderer.getNodeView ? this.renderer.getNodeView() : joint.dia.ElementView),
interactive: false
});
this.palette.on('cell:pointerup', (cellview: dia.CellView, evt: any) => {
if (this.viewBeingDragged) {
this.trigger({
type: Flo.DnDEventType.DROP,
view: this.viewBeingDragged,
event: evt
});
this.viewBeingDragged = undefined;
}
this.clickedElement = undefined;
$('#palette-floater').remove();
if (this.floaterpaper) {
this.floaterpaper.remove();
}
});
// Toggle the header open/closed on a click
this.palette.on('cell:pointerclick', (cellview: dia.CellView, event: any) => {
// TODO [design][palette] should the user need to click on the arrow rather than anywhere on the header?
// Click position within the element would be: evt.offsetX, evt.offsetY
const cell: dia.Cell = cellview.model;
if (cell.attributes.header) {
// Toggle the header open/closed
// if (cell.get('isOpen')) {
// this.rotateClosed(cell);
// } else {
// this.rotateOpen(cell);
// }
}
// TODO [palette] ensure other mouse handling events do nothing for headers
// TODO [palette] move 'metadata' field to the right place (not inside attrs I think)
});
$(this.document).on('mouseup', this.mouseUpHanlder);
if (this.metamodel) {
this.metamodel.load().then(data => {
this.buildPalette(data);
// Add listener to metamodel
if (this.metamodel && this.metamodel.subscribe) {
this.metamodel.subscribe(this._metamodelListener);
}
// Add debounced listener to filter text changes
this.filterTextModel
.pipe(debounceTime(DEBOUNCE_TIME))
.subscribe((value) => this.layout());
this.initialized = true;
});
} else {
Logger.error('No Metamodel service specified for palette!');
}
this._paletteSize = this._paletteSize || $(this.element.nativeElement.parentNode).width();
}
ngOnDestroy() {
if (this.metamodel && this.metamodel.unsubscribe) {
this.metamodel.unsubscribe(this._metamodelListener);
}
$(this.document).off('mouseup', this.mouseUpHanlder);
this.palette.remove();
}
private createPaletteGroup(title: string, isOpen: boolean): dia.Element {
const paletteRenderer: Flo.PaletteRenderer = this.renderer && this.renderer.getPaletteRenderer ? this.renderer.getPaletteRenderer() : {
createGroupHeader: (titleStr: string, isOpenParam: boolean) => {
const header = new joint.shapes.flo.PaletteGroupHeader({attrs: {text: {text: titleStr}}});
if (!isOpenParam) {
header.attr({'path': {'transform': 'rotate(-90,15,13)'}});
}
return header;
},
onClose: (groupView: dia.CellView) => this.rotateClosed(groupView.model),
onOpen: (groupView: dia.CellView) => this.rotateOpen(groupView.model)
};
let newGroupHeader = paletteRenderer.createGroupHeader(title, isOpen);
if (!isOpen) {
newGroupHeader.set('isOpen', false);
}
newGroupHeader.set('header', title);
this.paletteGraph.addCell(newGroupHeader);
const view = this.palette.findViewByModel(newGroupHeader);
view.on('cell:pointerclick', () => {
if (newGroupHeader.get('isOpen')) {
if (typeof paletteRenderer.onClose === 'function') {
paletteRenderer.onClose(view).then(() => {
newGroupHeader.set('isOpen', false);
this.closedGroups.add(newGroupHeader.get('header'));
this.layout();
});
} else {
newGroupHeader.set('isOpen', false);
this.closedGroups.add(newGroupHeader.get('header'));
this.layout();
}
} else {
if (typeof paletteRenderer.onOpen === 'function') {
paletteRenderer.onOpen(view).then(() => {
newGroupHeader.set('isOpen', true);
this.closedGroups.delete(newGroupHeader.get('header'));
this.layout();
});
} else {
newGroupHeader.set('isOpen', true);
this.closedGroups.delete(newGroupHeader.get('header'));
this.layout();
}
}
});
return newGroupHeader;
}
private createPaletteEntry(title: string, metadata: Flo.ElementMetadata) {
return Shapes.Factory.createNode({
renderer: this.renderer,
paper: this.palette,
metadata: metadata
});
}
private buildPalette(metamodel: Map<string, Map<string, Flo.ElementMetadata>>) {
let startTime: number = new Date().getTime();
this.paletteReady.emit(false);
this.paletteGraph.clear();
let groupAdded: Set<string> = new Set<string>();
let parentWidth: number = this._paletteSize - Flo.SCROLLBAR_WIDTH;
Logger.debug(`Parent Width: ${parentWidth}`);
// The field closedGroups tells us which should not be shown
// Work out the list of active groups/nodes based on the filter text
this.metamodel.groups().forEach(group => {
if (metamodel && metamodel.has(group)) {
Array.from(metamodel.get(group)!.keys()).sort().forEach(name => {
let node = metamodel.get(group)!.get(name);
if (node) {
if (!groupAdded.has(group)) {
this.createPaletteGroup(group, !this.closedGroups.has(group));
groupAdded.add(group);
}
if (!(node.metadata && node.metadata.noPaletteEntry)) {
this.createPaletteEntry(name, node);
}
}
});
}
});
this.noMacthesFoundNode = new joint.shapes.flo.NoMatchesFound();
this.palette.model.addCell(this.noMacthesFoundNode);
this.layout();
this.paletteReady.emit(true);
Logger.debug('buildPalette took ' + (new Date().getTime() - startTime) + 'ms');
}
private layout() {
let startTime: number = new Date().getTime();
let filterText = this.filterText;
if (filterText) {
filterText = filterText.toLowerCase();
}
let paletteNodes: Array<dia.Cell> = [];
let parentWidth: number = this._paletteSize - Flo.SCROLLBAR_WIDTH;
Logger.debug(`Parent Width: ${parentWidth}`);
const presentGroups = new Set<string>();
this.palette.model.getCells().forEach((cell: dia.Cell) => {
const metadata: Flo.ElementMetadata = cell.get('metadata');
if (cell.get('header')) {
paletteNodes.push(cell);
} else if (metadata && metadata.group && metadata.name
&& (!filterText
|| metadata.group.toLocaleLowerCase().indexOf(filterText) >= 0
|| metadata.name.toLocaleLowerCase().indexOf(filterText) >= 0)
) {
if (!this.closedGroups.has(metadata.group)) {
cell.attr('./display', 'block');
cell.removeAttr('./display');
paletteNodes.push(cell);
} else {
cell.attr('./display', 'none');
}
presentGroups.add(metadata.group);
} else {
if (cell === this.noMacthesFoundNode) {
} else {
cell.attr('./display', 'none');
}
}
});
// Clean group headers
const filteredGroupHeaders: dia.Cell[] = [];
paletteNodes.forEach(cell => {
if (cell.get('header')) {
if (presentGroups.has(cell.get('header'))) {
cell.attr('./display', 'block');
cell.removeAttr('./display');
filteredGroupHeaders.push(cell);
} else {
cell.attr('./display', 'none');
}
} else {
filteredGroupHeaders.push(cell);
}
});
paletteNodes = filteredGroupHeaders;
// Check if last group is empty
const previous = paletteNodes.length > 0 ? paletteNodes[paletteNodes.length - 1] : undefined;
// If previous is a paletter header node as well then the previous header had no nodes under it and we can hide it and remove from paletteNodes aeeay
if (previous && previous.get('header') && !this.closedGroups.has(previous.get('header'))) {
paletteNodes.pop().attr('./display', 'none');
}
let cellWidth = 0, cellHeight = 0;
// Determine the size of the palette entry cell (width and height)
paletteNodes.forEach(pnode => {
if (pnode.get('metadata')?.name) {
const elementSize = this.palette.findViewByModel(pnode).getBBox();
let dimension: dia.Size = {
width: elementSize.width,
height: elementSize.height
};
if (cellWidth < dimension.width) {
cellWidth = dimension.width;
}
if (cellHeight < dimension.height) {
cellHeight = dimension.height;
}
}
});
// Adjust the palette entry cell size with paddings.
cellWidth += 2 * this.paletteEntryPadding.width;
cellHeight += 2 * this.paletteEntryPadding.height;
// Align palette entries row to be at the center
let startX: number = parentWidth >= cellWidth ? (parentWidth - Math.floor(parentWidth / cellWidth) * cellWidth) / 2 : 0;
let xpos = startX;
let ypos = 0;
let prevNode: dia.Cell;
// Layout palette entry nodes
paletteNodes.forEach(pnode => {
const elementSize = this.palette.findViewByModel(pnode).getBBox();
let dimension: dia.Size = {
width: elementSize.width,
height: elementSize.height
};
if (pnode.get('header')) { //attributes.attrs.header) {
// Palette entry header
xpos = startX;
if (ypos) {
ypos += this.paletteEntryPadding.height;
}
pnode.set('size', {width: parentWidth, height: pnode.get('size').height || 30});
pnode.set('position', {x: 0, y: ypos});
ypos += dimension.height + this.paletteEntryPadding.height;
} else {
// Palette entry element
if (xpos + cellWidth > parentWidth) {
// Not enough real estate to place entry in a row - reset x position and leave the y pos which is next line
xpos = startX;
pnode.set('position', { x: xpos + (cellWidth - dimension.width) / 2, y: ypos + (cellHeight - dimension.height) / 2});
} else {
// Enough real estate to place entry in a row - adjust y position
if (prevNode && prevNode.get('metadata')?.name) {
ypos -= cellHeight;
}
pnode.set('position', { x: xpos + (cellWidth - dimension.width) / 2, y: ypos + (cellHeight - dimension.height) / 2});
}
// increment x position and y position (can be reorganized)
xpos += cellWidth;
ypos += cellHeight;
}
prevNode = pnode;
});
this.noMacthesFoundNode.set('size', {width: parentWidth, height: this.noMacthesFoundNode.get('size').height || 30});
this.noMacthesFoundNode.set('position', {x: 0, y: 0});
if (paletteNodes.length === 0 && filterText) {
// There is a filter present but everything is filtered out
// Show no matches found node
this.noMacthesFoundNode.attr('./display', 'block');
this.noMacthesFoundNode.removeAttr('./display');
ypos = this.noMacthesFoundNode.get('size').height;
} else {
// Hide no matches node in all other cases
this.noMacthesFoundNode.attr('./display', 'none');
}
this.palette.setDimensions(parentWidth, ypos);
Logger.debug('buildPalette layout ' + (new Date().getTime() - startTime) + 'ms');
}
set filterText(text: string) {
if (this._filterText !== text) {
this._filterText = text;
this.filterTextModel.next(text);
}
}
get filterText(): string {
return this._filterText;
}
private getPaletteView(view: any): dia.Element {
let self: Palette = this;
return view.extend({
pointerdown: function(/*evt, x, y*/) {
// Remove the tooltip
// $('.node-tooltip').remove();
// TODO move metadata to the right place (not inside attrs I think)
self.clickedElement = this.model;
if (self.clickedElement && self.clickedElement.get('metadata')) {
$(self.document).on('mousemove', self.mouseMoveHanlder);
}
},
pointermove: function(/*evt, x, y*/) {
// Nothing to prevent move within the palette canvas
},
// events: {
// // Tooltips on the palette elements
// 'mouseenter': function(evt) {
//
// // Ignore 'mouseenter' if any other buttons are pressed
// if (evt.buttons) {
// return;
// }
//
// var model = this.model;
// var metadata = model.get('metadata');
// if (!metadata) {
// return;
// }
//
// this.showTooltip(evt.pageX, evt.pageY);
// },
// // TODO bug here - if the call to get the info takes a while, the tooltip may appear after the pointer has left the cell
// 'mouseleave': function(/*evt, x,y*/) {
// this.hideTooltip();
// },
// 'mousemove': function(evt) {
// this.moveTooltip(evt.pageX, evt.pageY);
// }
// },
// showTooltip: function(x, y) {
// var model = this.model;
// var metadata = model.get('metadata');
// // TODO refactor to use tooltip module
// var nodeTooltip = document.createElement('div');
// $(nodeTooltip).addClass('node-tooltip');
// $(nodeTooltip).appendTo($('body')).fadeIn('fast');
// var nodeDescription = document.createElement('div');
// $(nodeTooltip).addClass('tooltip-description');
// $(nodeTooltip).append(nodeDescription);
//
// metadata.get('description').then(function(description) {
// $(nodeDescription).text(description ? description: model.attr('metadata/name'));
// }, function() {
// $(nodeDescription).text(model.attr('metadata/name'));
// });
//
// if (!metadata.metadata || !metadata.metadata['hide-tooltip-options']) {
// metadata.get('properties').then(function(metaProps) {
// if (metaProps) {
// Object.keys(metaProps).sort().forEach(function(propertyName) {
// var optionRow = document.createElement('div');
// var optionName = document.createElement('span');
// var optionDescription = document.createElement('span');
// $(optionName).addClass('node-tooltip-option-name');
// $(optionDescription).addClass('node-tooltip-option-description');
// $(optionName).text(metaProps[propertyName].name);
// $(optionDescription).text(metaProps[propertyName].description);
// $(optionRow).append(optionName);
// $(optionRow).append(optionDescription);
// $(nodeTooltip).append(optionRow);
// });
// }
// }, function(error) {
// if (error) {
// $log.error(error);
// }
// });
// }
//
// var mousex = x + 10;
// var mousey = y + 10;
// $('.node-tooltip').css({ top: mousey, left: mousex });
// },
//
// hideTooltip: function() {
// $('.node-tooltip').remove();
// },
//
// moveTooltip: function(x, y) {
// var mousex = x + 10; // Get X coordinates
// var mousey = y + 10; // Get Y coordinates
// $('.node-tooltip').css({ top: mousey, left: mousex });
// }
});
}
private handleMouseUp(event: any) {
$(this.document).off('mousemove', this.mouseMoveHanlder);
}
private trigger(event: Flo.DnDEvent) {
this.onPaletteEntryDrop.emit(event);
}
private handleDrag(event: any) {
// TODO offsetX/Y not on firefox
// Logger.debug("tracking move: x="+event.pageX+",y="+event.pageY);
// Logger.debug('Element = ' + (this.clickedElement ? this.clickedElement.attr('metadata/name'): 'null'));
if (this.clickedElement && this.clickedElement.get('metadata')) {
if (!this.viewBeingDragged) {
let dataOfClickedElement: Flo.ElementMetadata = this.clickedElement.get('metadata');
// custom div if not already built.
$('<div>', {
id: 'palette-floater'
}).appendTo($('body'));
let floatergraph: dia.Graph = new joint.dia.Graph();
floatergraph.set('type', Constants.FEEDBACK_CONTEXT);
const parent = $('#palette-floater');
this.floaterpaper = new joint.dia.Paper({
el: $('#palette-floater'),
elementView: this.renderer && this.renderer.getNodeView ? this.renderer.getNodeView() : joint.dia.ElementView,
gridSize: 10,
model: floatergraph,
height: parent.height(),
width: parent.width(),
validateMagnet: () => false,
validateConnection: () => false
});
// TODO float thing needs to be bigger otherwise icon label is missing
// Initiative drag and drop - create draggable element
let floaternode: dia.Element = Shapes.Factory.createNode({
'renderer': this.renderer,
'paper': this.floaterpaper,
'graph': floatergraph,
'metadata': dataOfClickedElement
});
// Only node view expected
this.viewBeingDragged = this.floaterpaper.findViewByModel(floaternode);
const resizeObserver = new ResizeObserver(() => {
if (this.viewBeingDragged) {
const box: dia.BBox = (<dia.ElementView>this.viewBeingDragged).getBBox();
const size: dia.Size = (<dia.ElementView>this.viewBeingDragged).model.size();
// Account for ports. Especially on the left side. Box includes ports, size does not
parent.css('width', box.width + box.width - size.width);
parent.css('height', box.height + box.height - size.height);
floaternode.position(box.width - size.width, box.height - size.height);
}
});
resizeObserver.observe(this.viewBeingDragged.el);
let box: dia.BBox = (<dia.ElementView>this.viewBeingDragged).getBBox();
let size: dia.Size = floaternode.size();
// Account for node real size including ports
parent.css('width', box.width + box.width - size.width);
parent.css('height', box.height + box.height - size.height);
floaternode.position(box.width - size.width, box.height - size.height);
parent.offset({left: event.pageX + 5, top: event.pageY + 5});
} else {
$('#palette-floater').offset({left: event.pageX + 5, top: event.pageY + 5});
this.trigger({
type: Flo.DnDEventType.DRAG,
view: this.viewBeingDragged,
event: event
});
}
}
}
/*
* Modify the rotation of the arrow in the header from horizontal(closed) to vertical(open)
*/
private rotateOpen(element: dia.Cell): Promise<void> {
return new Promise(resolve => {
setTimeout(() => this.doRotateOpen(element, 90).then(() => {
resolve();
}));
});
}
private doRotateOpen(element: dia.Cell, angle: number): Promise<void> {
return new Promise(resolve => {
angle -= 10;
element.attr({'path': {'transform': 'rotate(-' + angle + ',15,13)'}});
if (angle <= 0) {
resolve();
} else {
setTimeout(() => this.doRotateOpen(element, angle).then(() => resolve()), 10);
}
});
}
private doRotateClose(element: dia.Cell, angle: number): Promise<void> {
return new Promise(resolve => {
angle += 10;
element.attr({'path': {'transform': 'rotate(-' + angle + ',15,13)'}});
if (angle >= 90) {
resolve();
} else {
setTimeout(() => this.doRotateClose(element, angle).then(() => resolve()), 10);
}
});
}
// TODO better name for this function as this does the animation *and* updates the palette
/*
* Modify the rotation of the arrow in the header from vertical(open) to horizontal(closed)
*/
private rotateClosed(element: dia.Cell): Promise<void> {
return new Promise(resolve => {
setTimeout(() => {
this.doRotateClose(element, 0).then(() => {
resolve();
});
});
});
}
}

View File

@@ -0,0 +1,47 @@
<tr [formGroup]="form" class="df-property-row" [ngClass]="{'invalid-property-value': control.invalid}">
<td class="df-property-label-cell">
<label [attr.for]="model.id" class="df-form-label">{{model.name}}</label>
</td>
<td class="df-property-control-cell">
<div [ngSwitch]="model.type" class="df-property-container">
<label *ngSwitchCase="types.CHECKBOX" class="df-property-control">
<input type="checkbox" [id]="model.id" [(ngModel)]="model.value" [formControlName]="model.id">
{{model.value ? 'True' : 'False' }}
</label>
<input *ngSwitchCase="types.NUMBER" class="df-property-control" type="number" [id]="model.id"
[formControlName]="model.id" [placeholder]="model.defaultValue || ''" [(ngModel)]="model.value">
<input *ngSwitchCase="types.PASSWORD" class="df-property-control" type="password" [id]="model.id"
[formControlName]="model.id" [placeholder]="model.defaultValue || ''" [(ngModel)]="model.value">
<input *ngSwitchCase="types.EMAIL" class="df-property-control" type="password" [id]="model.id"
[formControlName]="model.id" [placeholder]="model.defaultValue || ''" [(ngModel)]="model.value">
<input *ngSwitchCase="types.URL" class="df-property-control" type="url" [id]="model.id"
[formControlName]="model.id" [placeholder]="model.defaultValue || ''" [(ngModel)]="model.value">
<select *ngSwitchCase="types.SELECT" class="df-property-control" [id]="model.id"
[formControlName]="model.id" [(ngModel)]="model.value">
<option *ngFor="let o of model['options']" [ngValue]="o.value">{{o.name}}</option>
</select>
<code-editor *ngSwitchCase="types.CODE" class="df-property-control" [id]="model.id"
[formControlName]="model.id" [language]="model['language']" [(ngModel)]="model.value" [line-numbers]="true"
scrollbar-style="simple" [placeholder]="model.defaultValue || 'Enter code snippet...'" [overview-ruler]="true">
</code-editor>
<input *ngSwitchDefault class="df-property-control" type="text" [id]="model.id" [formControlName]="model.id"
[placeholder]="model.defaultValue || ''" [(ngModel)]="model.value">
</div>
<div class="help-block">
<div>{{model.description}}</div>
<div *ngFor="let e of errorData" class="validation-error-block">{{e.message}}</div>
</div>
</td>
</tr>

View File

@@ -0,0 +1,32 @@
import { Component, Input, ViewEncapsulation } from '@angular/core';
import { FormGroup, AbstractControl } from '@angular/forms';
import { Properties } from '../shared/flo-properties';
@Component({
selector: 'df-property',
templateUrl: './df.property.component.html',
encapsulation: ViewEncapsulation.None
})
export class DynamicFormPropertyComponent {
@Input()
model: Properties.ControlModel<any>;
@Input() form: FormGroup;
constructor() {}
get types() {
return Properties.InputType;
}
get control(): AbstractControl {
return this.form.controls[this.model.id];
}
get errorData() {
return (this.model.validation && this.model.validation.errorData ? this.model.validation.errorData : [])
.filter(e => this.control.errors && this.control.errors[e.id]);
}
}

View File

@@ -0,0 +1,12 @@
<div *ngIf="propertiesGroupModel && !propertiesGroupModel.isLoading" class="properties-group-container"
[formGroup]="form">
<div
*ngIf="propertiesGroupModel.getControlsModels().length > 0 && controlModelsToDisplay.length === 0; else propertiesList"
class="no-matches-label">No results found.
</div>
</div>
<ng-template #propertiesList>
<df-property *ngFor="let model of controlModelsToDisplay" [model]="model" [form]="form"
class="property-row"></df-property>
</ng-template>

View File

@@ -0,0 +1,49 @@
import { Component, Input, OnInit, ViewEncapsulation } from '@angular/core';
import { FormGroup, FormControl } from '@angular/forms';
import { Properties } from '../shared/flo-properties';
import PropertyFilter = Properties.PropertyFilter;
@Component({
selector: 'properties-group',
templateUrl: './properties.group.component.html',
encapsulation: ViewEncapsulation.None
})
export class PropertiesGroupComponent implements OnInit {
@Input()
propertiesGroupModel: Properties.PropertiesGroupModel;
@Input()
form: FormGroup;
@Input()
filter: PropertyFilter;
ngOnInit() {
if (this.propertiesGroupModel.isLoading) {
let subscription = this.propertiesGroupModel.loadedSubject.subscribe(loaded => {
if (loaded) {
subscription.unsubscribe();
this.createGroupControls();
}
})
} else {
this.createGroupControls();
}
}
createGroupControls() {
this.propertiesGroupModel.getControlsModels().forEach(c => {
if (c.validation) {
this.form.addControl(c.id, new FormControl(c.value || '', c.validation.validator, c.validation.asyncValidator));
} else {
this.form.addControl(c.id, new FormControl(c.value || ''));
}
})
}
get controlModelsToDisplay() {
return this.propertiesGroupModel.getControlsModels().filter(c => !this.filter || this.filter.accept(c.property));
}
}

View File

@@ -0,0 +1,278 @@
import { dia, g } from 'jointjs';
import { Observable } from 'rxjs';
import * as _joint from 'jointjs';
import $ from 'jquery';
export namespace Flo {
export const joint: any = _joint;
export enum DnDEventType {
DRAG,
DROP
}
export interface DnDEvent {
type: DnDEventType;
view: dia.CellView;
event: MouseEvent;
}
export interface PropertyMetadata {
readonly id: string;
readonly name: string;
readonly description?: string;
readonly defaultValue?: any;
readonly type?: string;
readonly [propName: string]: any;
}
export interface ExtraMetadata {
readonly titleProperty?: string;
readonly noEditableProps?: boolean;
readonly noPaletteEntry?: boolean;
readonly unselectable?: boolean;
readonly [propName: string]: any;
readonly allowAdditionalProperties?: boolean; //TODO: Verify it is still needed
}
export interface ElementMetadata {
readonly name: string;
readonly group: string;
readonly metadata?: ExtraMetadata;
readonly [propName: string]: any;
description?(): Promise<string>;
get(property: String): Promise<PropertyMetadata>;
properties(): Promise<Map<string, PropertyMetadata>>;
}
export interface ViewerDescriptor {
readonly graph?: dia.Graph;
readonly paper?: dia.Paper;
}
export interface MetamodelListener {
metadataError(data: any): void;
metadataAboutToChange(): void;
metadataChanged(): void;
}
export interface Metamodel {
textToGraph(flo: EditorContext, dsl: string): Promise<any>;
graphToText(flo: EditorContext): Promise<string>;
load(): Promise<Map<string, Map<string, ElementMetadata>>>;
groups(): Array<string>;
refresh?(): Promise<Map<string, Map<string, ElementMetadata>>>;
subscribe?(listener: MetamodelListener): void;
unsubscribe?(listener: MetamodelListener): void;
isValidPropertyValue?(element: dia.Element, property: string, value: any): boolean;
}
export interface CreationParams {
metadata?: ElementMetadata;
props?: Map<string, any>;
}
export interface ElementCreationParams extends CreationParams {
position?: dia.Point;
}
export interface LinkCreationParams extends CreationParams {
source: string;
target: string;
}
export interface EmbeddedChildCreationParams extends CreationParams {
parent: dia.Cell;
position?: dia.Point;
}
export interface DecorationCreationParams extends EmbeddedChildCreationParams {
kind: string;
messages: Array<string>;
}
export interface HandleCreationParams extends EmbeddedChildCreationParams {
kind: string;
}
export interface PaletteRenderer {
createGroupHeader(title: string, isOpen: boolean): dia.Element;
onOpen?(groupView: dia.CellView): Promise<any>;
onClose?(groupView: dia.CellView): Promise<any>;
}
export interface Renderer {
createNode?(viewerDescriptor: ViewerDescriptor, metadata: ElementMetadata, props?: Map<string, any>): dia.Element;
createLink?(source: LinkEnd, target: LinkEnd, metadata?: ElementMetadata, props?: Map<string, any>): dia.Link;
createHandle?(kind: string, parent: dia.Cell): dia.Element;
createDecoration?(kind: string, parent: dia.Cell): dia.Element;
getPaletteRenderer?(): PaletteRenderer;
initializeNewNode?(node: dia.Element, viewerDescriptor: ViewerDescriptor): void;
initializeNewLink?(link: dia.Link, viewerDescriptor: ViewerDescriptor): void;
initializeNewHandle?(handle: dia.Element, viewerDescriptor: ViewerDescriptor): void;
initializeNewDecoration?(decoration: dia.Element, viewerDescriptor: ViewerDescriptor): void;
getNodeView?(): dia.ElementView;
getLinkView?(): dia.LinkView;
layout?(paper: dia.Paper): Promise<any>;
handleLinkEvent?(context: EditorContext, event: string, link: dia.Link): void;
isSemanticProperty?(propertyPath: string, element: dia.Cell): boolean;
refreshVisuals?(cell: dia.Cell, propertyPath: string, paper: dia.Paper): void;
getLinkAnchorPoint?(linkView: dia.LinkView, view: dia.ElementView, port: SVGElement, reference: dia.Point): dia.Point;
markersChanged?(cell: dia.Cell, paper: dia.Paper): void;
}
export interface EditorContext {
readonly textToGraphConversionObservable: Observable<void>;
readonly graphToTextConversionObservable: Observable<void>;
readonly paletteReady: Observable<boolean>;
zoomPercent: number;
gridSize: number;
readOnlyCanvas: boolean;
selection: dia.CellView;
graphToTextSync: boolean;
noPalette: boolean;
setDsl(dsl: string): void;
updateGraph(): Promise<any>;
updateText(): Promise<any>;
performLayout(): Promise<void>;
clearGraph(): Promise<void>;
getGraph(): dia.Graph;
getPaper(): dia.Paper;
getMinZoom(): number;
getMaxZoom(): number;
getZoomStep(): number;
fitToPage(): void;
createNode(metadata: ElementMetadata, props?: Map<string, any>, position?: dia.Point): dia.Element;
createLink(source: LinkEnd, target: LinkEnd, metadata?: ElementMetadata, props?: Map<string, any>): dia.Link;
deleteSelectedNode(): void;
delete(cell: dia.Cell): void;
[propName: string]: any;
}
export interface LinkEndDescriptor {
view: dia.CellView;
cssClassSelector?: string;
}
export interface DnDDescriptor {
sourceComponent?: string;
range?: number;
source?: LinkEndDescriptor;
target?: LinkEndDescriptor;
}
export interface LinkEnd {
id: string | number;
selector?: string;
port?: string;
magnet?: string;
}
export enum Severity {
Error,
Warning
}
export interface Marker {
severity: Severity;
message: string;
range?: Range;
}
export interface Position {
ch: number;
line: number;
}
export interface Range {
start: Position;
end: Position;
}
export interface Editor {
interactive?: ((cellView: dia.CellView, event: string) => boolean) | boolean | dia.CellView.InteractivityOptions;
allowLinkVertexEdit?: boolean;
highlighting?: any;
createHandles?(context: EditorContext, createHandle: (owner: dia.CellView, kind: string, action: () => void, location: dia.Point) => void, owner: dia.CellView): void;
validatePort?(context: EditorContext, view: dia.CellView, magnet: SVGElement): boolean;
validateLink?(context: EditorContext, cellViewS: dia.CellView, portS: SVGElement, cellViewT: dia.CellView, portT: SVGElement, isSource: boolean, linkView: dia.LinkView): boolean;
calculateDragDescriptor?(context: EditorContext, draggedView: dia.CellView, targetUnderMouse: dia.CellView, coordinate: g.Point, sourceComponent: string): DnDDescriptor;
handleNodeDropping?(context: EditorContext, dragDescriptor: DnDDescriptor): void;
showDragFeedback?(context: EditorContext, dragDescriptor: DnDDescriptor): void;
hideDragFeedback?(context: EditorContext, dragDescriptor: DnDDescriptor): void;
validate?(graph: dia.Graph, dsl: string, flo: EditorContext): Promise<Map<string | number, Array<Marker>>>;
preDelete?(context: EditorContext, deletedElement: dia.Cell): boolean;
setDefaultContent?(editorContext: EditorContext, data: Map<string, Map<string, ElementMetadata>>): void;
}
export function findMagnetByClass(view: dia.CellView, className: string): SVGElement | undefined {
if (className && className.startsWith('.')) {
className = className.substr(1);
}
const element = view.$('[magnet]').toArray().find((magnet: any) => magnet.getAttribute('class').split(/\s+/).indexOf(className) >= 0);
if (element) {
return view.findMagnet($(element));
}
}
export function findMagnetByPort(view: dia.CellView, port: string): SVGElement | undefined {
const element = view.$('[magnet]').toArray().find((magnet: HTMLElement) => magnet.getAttribute('port') === port);
if (element) {
return view.findMagnet($(element));
}
}
/**
* Return the metadata for a particular palette entry in a particular group.
* @param name - name of the palette entry
* @param group - group in which the palette entry should exist (e.g. sinks)
* @return
*/
export function getMetadata(metamodel: Map<string, Map<string, ElementMetadata>>, name: string, group: string): ElementMetadata | undefined {
const groupObj = metamodel && group ? metamodel.get(group) : undefined;
if (name && groupObj && groupObj.get(name)) {
return metamodel!.get(group)!.get(name);
} else {
return {
name: name,
group: group,
unresolved: true,
get: (property: string) => new Promise<PropertyMetadata>(resolve => resolve(undefined)),
properties: () => Promise.resolve(new Map<string, PropertyMetadata>())
};
}
}
export function getScrollbarWidth() {
// Creating invisible container
const outer = document.createElement('div');
outer.style.visibility = 'hidden';
outer.style.overflow = 'scroll'; // forcing scrollbar to appear
// outer.style.msOverflowStyle = 'scrollbar'; // needed for WinJS apps
document.body.appendChild(outer);
// Creating inner element and placing it in the container
const inner = document.createElement('div');
outer.appendChild(inner);
// Calculating difference between container's full width and the child width
const scrollbarWidth = (outer.offsetWidth - inner.offsetWidth);
// Removing temporary elements from the DOM
outer.parentNode.removeChild(outer);
return scrollbarWidth;
}
export const SCROLLBAR_WIDTH = getScrollbarWidth();
}

View File

@@ -0,0 +1,390 @@
import { dia } from 'jointjs';
import { ValidatorFn, AsyncValidatorFn, AbstractControl, ValidationErrors } from '@angular/forms'
import { Flo } from './flo-common';
import { Subject, Observable } from 'rxjs'
import {debounceTime, switchMap} from 'rxjs/operators';
export namespace Properties {
export enum InputType {
TEXT,
NUMBER,
SELECT,
CHECKBOX,
PASSWORD,
EMAIL,
URL,
CODE
}
export interface Property {
readonly id: string;
readonly name: string;
readonly type?: string;
readonly description?: string;
readonly defaultValue?: any;
value?: any;
readonly valueOptions?: any[]
readonly [propName: string]: any;
}
export interface PropertyFilter {
accept: (property: Property) => boolean;
}
export interface SelectOption {
name: string;
value: any;
}
export interface ErrorData {
id: string;
message: string;
}
export interface Validation {
validator?: ValidatorFn|ValidatorFn[]|null,
asyncValidator?: AsyncValidatorFn|AsyncValidatorFn[]|null,
errorData?: Array<ErrorData>;
}
export interface ControlModel<T> {
readonly type: InputType;
readonly id: string;
value: T;
readonly defaultValue: T;
readonly name?: string;
readonly description?: string;
readonly property: Property
readonly validation?: Validation;
}
export interface CodeControlModel<T> extends ControlModel<T> {
readonly language: string;
}
export class GenericControlModel<T> implements ControlModel<T> {
constructor(private _property: Property, public type: InputType, public validation?: Validation) {}
get id() {
return this.property.id;
}
get name() {
return this.property.name;
}
get description() {
return this.property.description;
}
get defaultValue() {
return this.property.defaultValue;
}
get value(): T {
return this.getValue();
}
set value(value: T) {
this.setValue(value);
}
get property(): Property {
return this._property;
}
protected setValue(value: T) {
this.property.value = value;
}
protected getValue(): T {
return this.property.value;
}
}
export class CheckBoxControlModel extends GenericControlModel<boolean> {
constructor(_property: Property, validation?: Validation) {
super(_property, InputType.CHECKBOX, validation);
}
protected getValue() {
const res = super.getValue();
const type = typeof res;
switch (type) {
case 'boolean':
return res;
case 'string':
switch ((<any>res).trim().toLowerCase()) {
case 'true':
case '1':
return true;
case 'false':
case '0':
return false;
default:
return this.property.defaultValue
}
case 'number':
const num = <any> res;
if (num === 0) {
return false;
} else if (num === 1) {
return true;
} else {
return this.property.defaultValue
}
}
return this.property.defaultValue;
}
}
export abstract class AbstractCodeControlModel extends GenericControlModel<string> implements CodeControlModel<string> {
abstract language: string;
constructor(_property: Property, private encode?: (s: string) => string, private decode?: (s: string) => string, validation?: Validation) {
super(_property, InputType.CODE, validation);
}
set value(value: string) {
if (value && this.encode) {
super.setValue(this.encode(value));
} else {
super.setValue(value);
}
}
get value(): string {
let dsl = super.getValue();
if (dsl && this.decode) {
return this.decode(dsl);
} else {
return dsl;
}
}
}
export class GenericCodeControlModel extends AbstractCodeControlModel {
constructor(_property: Property, public language: string, encode?: (s: string) => string, decode?: (s: string) => string, validation?: Validation) {
super(_property, encode, decode, validation);
}
}
export class CodeControlModelWithDynamicLanguageProperty extends AbstractCodeControlModel {
private _langControlModel: Properties.ControlModel<any>;
constructor(_property: Properties.Property, private _languagePropertyName: string,
private _groupModel: Properties.PropertiesGroupModel,
encode?: (s: string) => string, decode?: (s: string) => string, validation?: Validation) {
super(_property, encode, decode, validation);
}
get language(): string {
const value = this.languageControlModel.value;
return value ? value : this.languageControlModel.defaultValue;
}
get languageControlModel(): Properties.ControlModel<any> {
if (!this._langControlModel) {
// Cast to Properties.ControlModel<any> from Properties.ControlModel<any> | undefined
// Should not be undefined!
this._langControlModel = <Properties.ControlModel<any>> this._groupModel.getControlsModels().find(c => c.id === this._languagePropertyName);
}
return this._langControlModel;
}
}
export class GenericListControlModel extends GenericControlModel<string> {
constructor(property: Property, validation?: Validation) {
super(property, InputType.TEXT, validation);
}
get value(): string {
return this.property.value ? this.property.value.toString().trim().split(/\s*,\s*/).join(', ') : '';
}
set value(value: string) {
this.property.value = value && value.trim() ? value.split(/\s*,\s*/).join(',') : undefined;
}
}
export class SelectControlModel extends GenericControlModel<any> {
constructor(_property: Property, type: InputType, public options: Array<SelectOption>, validation?: Validation) {
super(_property, type, validation);
if (_property.defaultValue === undefined) {
options.unshift({
name: 'SELECT',
value: _property.defaultValue
})
}
}
}
export interface PropertiesSource {
getProperties(): Promise<Property[]>;
applyChanges(properties: Property[]): void;
}
export class DefaultCellPropertiesSource implements PropertiesSource {
protected cell: dia.Cell;
constructor(cell: dia.Cell) {
this.cell = cell;
}
getProperties(): Promise<Array<Property>> {
let metadata: Flo.ElementMetadata = this.cell.get('metadata');
return Promise.resolve(metadata.properties().then(propsMetadata => Array.from(propsMetadata.values()).map(m => this.createProperty(m))));
}
protected createProperty(metadata: Flo.PropertyMetadata): Property {
return {
id: metadata.id,
name: metadata.name,
type: metadata.type,
defaultValue: metadata.defaultValue,
attr: `props/${metadata.name}`,
value: this.cell.attr(`props/${metadata.name}`),
description: metadata.description,
valueOptions: metadata.options
}
}
applyChanges(properties: Property[]) {
this.cell.trigger('batch:start', { batchName: 'update properties' });
properties.forEach(property => {
if ((typeof property.value === 'boolean' && !property.defaultValue && !property.value) ||
(property.value === property.defaultValue || property.value === '' || property.value === undefined || property.value === null)) {
let currentValue = this.cell.attr(property.attr);
if (currentValue !== undefined && currentValue !== null) {
// Remove attr doesn't fire appropriate event. Set default value first as a workaround to schedule DSL resync
// this.cell.attr(property.attr, property.defaultValue === undefined ? null : property.defaultValue);
this.cell.attr(property.attr, null);
this.cell.removeAttr(property.attr);
}
} else {
this.cell.attr(property.attr, property.value);
}
});
this.cell.trigger('batch:stop', { batchName: 'update properties' });
}
}
export class PropertiesGroupModel {
protected propertiesSource: PropertiesSource;
protected controlModels: Array<ControlModel<any>>;
protected loading = true;
protected _loadedSubject: Subject<boolean>;
constructor(propertiesSource: PropertiesSource) {
this.propertiesSource = propertiesSource;
}
load() {
this.loading = true;
this._loadedSubject = new Subject<boolean>();
this.propertiesSource.getProperties().then(properties => {
this.controlModels = properties.map(p => this.createControlModel(p));
this.loading = false;
this._loadedSubject.next(true);
this._loadedSubject.complete();
});
}
get isLoading(): boolean {
return this.loading;
}
get loadedSubject() {
return this._loadedSubject;
}
getControlsModels() {
return this.controlModels;
}
protected createControlModel(property: Property): ControlModel<any> {
return new GenericControlModel(property, InputType.TEXT);
}
public applyChanges(): void {
if (this.loading) {
return;
}
let properties = this.controlModels.map(cm => cm.property);
this.propertiesSource.applyChanges(properties);
}
}
const UNIQUE_RESOURCE_ERROR = {uniqueResource: true};
export namespace Validators {
export function uniqueResource(service: (value: any) => Observable<boolean>, debounceDuration: number): AsyncValidatorFn {
return (control: AbstractControl): Observable<ValidationErrors> => {
return new Observable<ValidationErrors>(obs => {
if (control.valueChanges) {
return control.valueChanges.pipe(
debounceTime(debounceDuration),
switchMap(() => service(control.value)),
).subscribe((res) => {
if (res) {
obs.next(undefined);
} else {
obs.next(UNIQUE_RESOURCE_ERROR);
}
obs.complete();
}, () => {
obs.next(UNIQUE_RESOURCE_ERROR);
obs.complete();
});
} else {
service(control.value).subscribe((res) => {
if (res) {
obs.next(undefined);
} else {
obs.next(UNIQUE_RESOURCE_ERROR);
}
obs.complete();
}, () => {
obs.next(UNIQUE_RESOURCE_ERROR);
obs.complete();
})
}
});
}
}
export function noneOf(excluded: Array<any>): ValidatorFn {
return (control: AbstractControl): {[key: string]: any} => {
return excluded.find(e => e === control.value) ? {'noneOf': {value: control.value}} : {};
};
}
}
}

View File

@@ -0,0 +1,28 @@
import { isDevMode } from '@angular/core';
export class Logger {
constructor() {
}
static log(value: any, ...rest: any[]) {
if (isDevMode()) {
console.log(value, ...rest);
}
}
static debug(value: any, ...rest: any[]) {
if (isDevMode()) {
console.debug(value, ...rest);
}
}
static error(value: any, ...rest: any[]) {
console.error(value, ...rest);
}
static warn(value: any, ...rest: any[]) {
console.warn(value, ...rest);
}
}

View File

@@ -0,0 +1,805 @@
import { dia } from 'jointjs';
import { Flo } from './flo-common';
import template from 'lodash/template';
import isFunction from 'lodash/isFunction';
import $ from 'jquery';
const joint: any = Flo.joint;
const isChrome: boolean = /Chrome/.test(navigator.userAgent) && /Google Inc/.test(navigator.vendor);
const isFF: boolean = navigator.userAgent.indexOf('Firefox') > 0;
const IMAGE_W = 120;
const IMAGE_H = 35;
const ERROR_MARKER_SIZE: dia.Size = {width: 16, height: 16};
const HANDLE_SIZE: dia.Size = {width: 10, height: 10};
const HANDLE_ICON_MAP: Map<string, string> = new Map<string, string>();
const REMOVE = 'remove';
HANDLE_ICON_MAP.set(REMOVE, 'icons/delete.svg');
const DECORATION_ICON_MAP: Map<string, string> = new Map<string, string>();
const ERROR = 'error';
DECORATION_ICON_MAP.set(ERROR, 'icons/error.svg');
export function loadShapes() {
joint.shapes.flo = {};
joint.shapes.flo.NODE_TYPE = 'sinspctr.IntNode';
joint.shapes.flo.LINK_TYPE = 'sinspctr.Link';
joint.shapes.flo.DECORATION_TYPE = 'decoration';
joint.shapes.flo.HANDLE_TYPE = 'handle';
// joint.util.cloneDeep = (obj: any) => {
// return _.cloneDeepWith(obj, (o) => {
// if (_.isObject(o) && !_.isPlainObject(o)) {
// return o;
// }
// });
// };
joint.util.filter.redscale = (args: Shapes.FilterOptions) => {
let amount = Number.isFinite(args.amount) ? args.amount : 1;
return template(
'<filter><feColorMatrix type="matrix" values="${a} ${b} ${c} 0 ${d} ${e} ${f} ${g} 0 0 ${h} ${i} ${k} 0 0 0 0 0 1 0"/></filter>',
<any>{
a: 1 - 0.96 * amount,
b: 0.95 * amount,
c: 0.01 * amount,
d: 0.3 * amount,
e: 0.2 * amount,
f: 1 - 0.9 * amount,
g: 0.7 * amount,
h: 0.05 * amount,
i: 0.05 * amount,
k: 1 - 0.1 * amount
}
);
};
joint.util.filter.orangescale = (args: Shapes.FilterOptions) => {
let amount = Number.isFinite(args.amount) ? args.amount : 1;
return template(
'<filter><feColorMatrix type="matrix" values="${a} ${b} ${c} 0 ${d} ${e} ${f} ${g} 0 ${h} ${i} ${k} ${l} 0 0 0 0 0 1 0"/></filter>',
<any>{
a: 1.0 + 0.5 * amount,
b: 1.4 * amount,
c: 0.2 * amount,
d: 0.3 * amount,
e: 0.3 * amount,
f: 1 + 0.05 * amount,
g: 0.2 * amount,
h: 0.15 * amount,
i: 0.3 * amount,
k: 0.3 * amount,
l: 1 - 0.6 * amount
}
);
};
joint.shapes.flo.Node = joint.shapes.basic.Generic.extend({
markup:
'<g class="shape"><image class="image" /></g>' +
'<rect class="border-white"/>' +
'<rect class="border"/>' +
'<rect class="box"/>' +
'<text class="label"/>' +
'<text class="label2"></text>' +
'<rect class="input-port" />' +
'<rect class="output-port"/>' +
'<rect class="output-port-cover"/>',
defaults: joint.util.deepSupplement({
type: joint.shapes.flo.NODE_TYPE,
position: {x: 0, y: 0},
size: { width: IMAGE_W, height: IMAGE_H },
attrs: {
'.': { magnet: false },
// rounded edges around image
'.border': {
refWidth: 1,
refHeight: 1,
rx: 3,
ry: 3,
'fill-opacity': 0, // see through
stroke: '#eeeeee',
'stroke-width': 0
},
'.box': {
refWidth: 1,
refHeight: 1,
rx: 3,
ry: 3,
//'fill-opacity': 0, // see through
stroke: '#6db33f',
fill: '#eeeeee',
'stroke-width': 1
},
'.input-port': {
idp: 'input',
port: 'input',
height: 8, width: 8,
magnet: true,
fill: '#eeeeee',
transform: 'translate(' + -4 + ',' + ((IMAGE_H / 2 ) - 4) + ')',
stroke: '#34302d',
'stroke-width': 1
},
'.output-port': {
id: 'output',
port: 'output',
height: 8, width: 8,
magnet: true,
fill: '#eeeeee',
transform: 'translate(' + (IMAGE_W - 4) + ',' + ((IMAGE_H / 2) - 4) + ')',
stroke: '#34302d',
'stroke-width': 1
},
'.label': {
'text-anchor': 'middle',
'ref-x': 0.5, // jointjs specific: relative position to ref'd element
// 'ref-y': -12, // jointjs specific: relative position to ref'd element
'ref-y': 0.3,
ref: '.border', // jointjs specific: element for ref-x, ref-y
fill: 'black',
'font-size': 14
},
'.label2': {
'text': '\u21d2',
'text-anchor': 'middle',
'ref-x': 0.15, // jointjs specific: relative position to ref'd element
'ref-y': 0.2, // jointjs specific: relative position to ref'd element
ref: '.border', // jointjs specific: element for ref-x, ref-y
// transform: 'translate(' + (IMAGE_W/2) + ',' + (IMAGE_H/2) + ')',
fill: 'black',
'font-size': 24
},
'.shape': {
},
'.image': {
refWidth: 1,
refHeight: 1,
}
}
}, joint.shapes.basic.Generic.prototype.defaults)
});
joint.shapes.flo.Link = joint.dia.Link.extend({
defaults: joint.util.deepSupplement({
type: joint.shapes.flo.LINK_TYPE,
attrs: {
'.connection': { stroke: '#34302d', 'stroke-width': 2 },
// Lots of alternatives that have been played with:
// '.smoooth': true
// '.marker-source': { stroke: '#9B59B6', fill: '#9B59B6', d: 'M24.316,5.318,9.833,13.682,9.833,5.5,5.5,5.5,5.5,25.5,9.833,25.5,9.833,17.318,24.316,25.682z' },
// '.marker-target': { stroke: '#F39C12', fill: '#F39C12', d: 'M14.615,4.928c0.487-0.986,1.284-0.986,1.771,0l2.249,4.554c0.486,0.986,1.775,1.923,2.864,2.081l5.024,0.73c1.089,0.158,1.335,0.916,0.547,1.684l-3.636,3.544c-0.788,0.769-1.28,2.283-1.095,3.368l0.859,5.004c0.186,1.085-0.459,1.553-1.433,1.041l-4.495-2.363c-0.974-0.512-2.567-0.512-3.541,0l-4.495,2.363c-0.974,0.512-1.618,0.044-1.432-1.041l0.858-5.004c0.186-1.085-0.307-2.6-1.094-3.368L3.93,13.977c-0.788-0.768-0.542-1.525,0.547-1.684l5.026-0.73c1.088-0.158,2.377-1.095,2.864-2.081L14.615,4.928z' },
// '.connection': { 'stroke':'black'},
// '.': { filter: { name: 'dropShadow', args: { dx: 1, dy: 1, blur: 2 } } },
// '.connection': { 'stroke-width': 10, 'stroke-linecap': 'round' },
// This means: moveto 10 0, lineto 0 5, lineto, 10 10 closepath(z)
// '.marker-target': { d: 'M 5 0 L 0 7 L 5 14 z', stroke: '#34302d','stroke-width': 1},
// '.marker-target': { d: 'M 14 2 L 9,2 L9,0 L 0,7 L 9,14 L 9,12 L 14,12 z', 'stroke-width': 1, fill: '#34302d', stroke: '#34302d'},
// '.marker-source': {d: 'M 5 0 L 5,10 L 0,10 L 0,0 z', 'stroke-width': 0, fill: '#34302d', stroke: '#34302d'},
// '.marker-target': { stroke: '#E74C3C', fill: '#E74C3C', d: 'M 10 0 L 0 5 L 10 10 z' },
'.marker-arrowheads': { display: 'none' },
'.tool-options': { display: 'none' }
},
// connector: { name: 'normalDimFix' }
}, joint.dia.Link.prototype.defaults)
});
joint.shapes.flo.LinkView = joint.dia.LinkView.extend({
options: joint.util.deepSupplement({
linkToolsOffset: 0.5,
shortLinkLength: 40
}, joint.dia.LinkView.prototype.options),
updateToolsPosition: function() {
// Overriden to support relative offset for tools placement.
// If offset is between 0 and 1 then percentage of the connection length will be used to offset the tools group
if (this.options.linkToolsOffset < 1 && this.options.linkToolsOffset > 0) {
let connectionLength = this.getConnectionLength();
const relativeOffset = this.options.linkToolsOffset
this.options.linkToolsOffset = connectionLength * relativeOffset;
const returnValue = joint.dia.LinkView.prototype.updateToolsPosition.apply(this, arguments);
this.options.linkToolsOffset = relativeOffset;
return returnValue;
} else {
return joint.dia.LinkView.prototype.updateToolsPosition.apply(this, arguments);
}
},
_beforeArrowheadMove: function() {
if (this.model.get('source').id) {
this._oldSource = this.model.get('source');
}
if (this.model.get('target').id) {
this._oldTarget = this.model.get('target');
}
joint.dia.LinkView.prototype._beforeArrowheadMove.apply(this, arguments);
},
_afterArrowheadMove: function() {
joint.dia.LinkView.prototype._afterArrowheadMove.apply(this, arguments);
if (!this.model.get('source').id) {
if (this._oldSource) {
this.model.set('source', this._oldSource);
} else {
this.model.remove();
}
}
if (!this.model.get('target').id) {
if (this._oldTarget) {
this.model.set('target', this._oldTarget);
} else {
this.model.remove();
}
}
delete this._oldSource;
delete this._oldTarget;
}
});
// TODO: must do cleanup for the `mainElementView'
joint.shapes.flo.ElementView = joint.dia.ElementView.extend({
// canShowTooltip: true,
beingDragged: false,
// _tempZorder: 0,
_tempOpacity: 1.0,
_hovering: false,
dragLinkStart: function(evt: any, magnet: any, x: number, y: number) {
this.model.startBatch('add-link');
const linkView = this.addLinkFromMagnet(magnet, x, y);
// backwards compatiblity events
joint.dia.CellView.prototype.pointerdown.apply(linkView, [evt, x, y]);
linkView.notify('link:pointerdown', evt, x, y);
/*** START MAIN DIFF ***/
const sourceOrTarget = $(magnet).attr('port') === 'input' ? 'source' : 'target';
linkView.eventData(evt, linkView.startArrowheadMove(sourceOrTarget, { whenNotAllowed: 'remove' }));
/*** END MAIN DIFF ***/
this.eventData(evt, { linkView: linkView });
},
addLinkFromMagnet: function(magnet: any, x: number, y: number) {
const paper = this.paper;
const graph = paper.model;
const link = paper.getDefaultLink(this, magnet);
let sourceEnd, targetEnd: any;
/*** START MAIN DIFF ***/
if ($(magnet).attr('port') === 'input') {
sourceEnd = { x: x, y: y };
targetEnd = this.getLinkEnd(magnet, x, y, link, 'target');
} else {
sourceEnd = this.getLinkEnd(magnet, x, y, link, 'source');
targetEnd = { x: x, y: y };
}
/*** END MAIN DIFF ***/
link.set({
source: sourceEnd,
target: targetEnd
}).addTo(graph, {
async: false,
ui: true
});
return link.findView(paper);
},
// pointerdown: function(evt: any, x: number, y: number) {
// // this.canShowTooltip = false;
// // this.hideTooltip();
// this.beingDragged = false;
// this._tempOpacity = this.model.attr('./opacity');
//
// this.model.trigger('batch:start');
//
// if ( // target is a valid magnet start linking
// evt.target.getAttribute('magnet') &&
// this.paper.options.validateMagnet.call(this.paper, this, evt.target)
// ) {
// let link = this.paper.getDefaultLink(this, evt.target);
// if ($(evt.target).attr('port') === 'input') {
// link.set({
// source: { x: x, y: y },
// target: {
// id: this.model.id,
// selector: this.getSelector(evt.target),
// port: evt.target.getAttribute('port')
// }
// });
// } else {
// link.set({
// source: {
// id: this.model.id,
// selector: this.getSelector(evt.target),
// port: evt.target.getAttribute('port')
// },
// target: { x: x, y: y }
// });
// }
// this.paper.model.addCell(link);
// this._linkView = this.paper.findViewByModel(link);
// if ($(evt.target).attr('port') === 'input') {
// this._linkView.startArrowheadMove('source');
// } else {
// this._linkView.startArrowheadMove('target');
// }
// this.paper.__creatingLinkFromPort = true;
// } else {
// this._dx = x;
// this._dy = y;
// joint.dia.CellView.prototype.pointerdown.apply(this, arguments);
// }
// },
drag: function(evt: MouseEvent, x: number, y: number) {
let interactive = isFunction(this.options.interactive) ? this.options.interactive(this, 'pointermove') :
this.options.interactive;
if (interactive !== false) {
this.paper.trigger('dragging-node-over-canvas', {type: Flo.DnDEventType.DRAG, view: this, event: evt});
}
joint.dia.ElementView.prototype.drag.apply(this, arguments);
},
dragEnd: function(evt: MouseEvent, x: number, y: number) { // jshint ignore:line
this.paper.trigger('dragging-node-over-canvas', {type: Flo.DnDEventType.DROP, view: this, event: evt});
joint.dia.ElementView.prototype.dragEnd.apply(this, arguments);
},
// events: {
// // Tooltips on the elements in the graph
// 'mouseenter': function(evt: MouseEvent) {
// if (this.canShowTooltip) {
// this.showTooltip(evt.pageX, evt.pageY);
// }
// if (!this._hovering && !this.paper.__creatingLinkFromPort) {
// this._hovering = true;
// if (isChrome || isFF) {
// this._tempZorder = this.model.get('z');
// this.model.toFront({deep: true});
// }
// }
// },
// 'mouseleave': function() {
// this.hideTooltip();
// if (this._hovering) {
// this._hovering = false;
// if (isChrome || isFF) {
// this.model.set('z', this._tempZorder);
// var z = this._tempZorder;
// this.model.getEmbeddedCells({breadthFirst: true}).forEach(function(cell: dia.Cell) {
// cell.set('z', ++z);
// });
// }
// }
// },
// 'mousemove': function(evt: MouseEvent) {
// this.moveTooltip(evt.pageX, evt.pageY);
// }
// },
// showTooltip: function(x: number, y: number) {
// var mousex = x + 10;
// var mousey = y + 10;
//
// var nodeTooltip: HTMLElement;
// if (this.model instanceof joint.dia.Element && this.model.get('metadata')) {
// nodeTooltip = document.createElement('div');
// $(nodeTooltip).addClass('node-tooltip');
//
// $(nodeTooltip).appendTo($('body')).fadeIn('fast');
// $(nodeTooltip).addClass('tooltip-description');
// var nodeTitle = document.createElement('div');
// $(nodeTooltip).append(nodeTitle);
// var nodeDescription = document.createElement('div');
// $(nodeTooltip).append(nodeDescription);
//
// var model = this.model;
//
// if (model.attr('metadata/name')) {
// var typeSpan = document.createElement('span');
// $(typeSpan).addClass('tooltip-title-type');
// $(nodeTitle).append(typeSpan);
// $(typeSpan).text(model.attr('metadata/name'));
// if (model.attr('metadata/group')) {
// var groupSpan = document.createElement('span');
// $(groupSpan).addClass('tooltip-title-group');
// $(nodeTitle).append(groupSpan);
// $(groupSpan).text('(' + model.attr('metadata/group') + ')');
// }
// }
//
// model.get('metadata').get('description').then(function(description: string) {
// $(nodeDescription).text(description);
// }, function(error: any) {
// if (error) {
// Logger.error(error);
// }
// });
//
// // defaultValue
// if (!model.attr('metadata/metadata/hide-tooltip-options')) {
// model.get('metadata')?.get('properties').then(function(metaProps: any) {
// var props = model.attr('props'); // array of {'name':,'value':}
// if (metaProps && props) {
// Object.keys(props).sort().forEach(function(propertyName) {
// if (metaProps[propertyName]) {
// var optionRow = document.createElement('div');
// var optionName = document.createElement('span');
// var optionDescription = document.createElement('span');
// $(optionName).addClass('node-tooltip-option-name');
// $(optionDescription).addClass('node-tooltip-option-description');
// $(optionName).text(metaProps[propertyName].name);
// $(optionDescription).text(props[propertyName]);//nodeOptionData[i].description);
// $(optionRow).append(optionName);
// $(optionRow).append(optionDescription);
// $(nodeTooltip).append(optionRow);
// }
// // This was the code to add every parameter in:
// // $(optionName).addClass('node-tooltip-option-name');
// // $(optionDescription).addClass('node-tooltip-option-description');
// // $(optionName).text(metaProps[propertyName].name);
// // $(optionDescription).text(metaProps[propertyName].description);
// // $(optionRow).append(optionName);
// // $(optionRow).append(optionDescription);
// // $(nodeTooltip).append(optionRow);
// });
// }
// }, function(error: any) {
// if (error) {
// Logger.error(error);
// }
// });
// }
//
// $('.node-tooltip').css({ top: mousey, left: mousex });
// } else if (this.model.get('type') === joint.shapes.flo.DECORATION_TYPE && this.model.attr('./kind') === 'error') {
// Logger.debug('mouse enter: ERROR box=' + JSON.stringify(this.model.getBBox()));
// nodeTooltip = document.createElement('div');
// var errors = this.model.attr('messages');
// if (errors && errors.length > 0) {
// $(nodeTooltip).addClass('error-tooltip');
// $(nodeTooltip).appendTo($('body')).fadeIn('fast');
// var header = document.createElement('p');
// $(header).text('Errors:');
// $(nodeTooltip).append(header);
// for (var i = 0;i < errors.length; i++) {
// var errorElement = document.createElement('li');
// $(errorElement).text(errors[i]);
// $(nodeTooltip).append(errorElement);
// }
// $('.error-tooltip').css({ top: mousey, left: mousex });
// }
// }
// },
// hideTooltip: function() {
// $('.node-tooltip').remove();
// $('.error-tooltip').remove();
// },
// moveTooltip: function(x: number, y: number) {
// $('.node-tooltip')
// .css({ top: y + 10, left: x + 10 });
// $('.error-tooltip')
// .css({ top: y + 10, left: x + 10 });
// }
});
joint.shapes.flo.ErrorDecoration = joint.shapes.basic.Generic.extend({
markup: '<g class="rotatable"><g class="scalable"><image/></g></g>',
defaults: joint.util.deepSupplement({
type: joint.shapes.flo.DECORATION_TYPE,
size: ERROR_MARKER_SIZE,
attrs: {
'image': ERROR_MARKER_SIZE
}
}, joint.shapes.basic.Generic.prototype.defaults)
});
joint.shapes.flo.PaletteGroupHeader = joint.shapes.basic.Generic.extend({
// The path is the open/close arrow, defaults to vertical (open)
markup: '<g class="scalable"><rect/></g><text/><g class="rotatable"><path d="m 10 10 l 5 8.7 l 5 -8.7 z"/></g>',
defaults: joint.util.deepSupplement({
type: 'palette.groupheader',
size: {width: 170, height: 30},
position: {x: 0, y: 0},
attrs: {
'rect': { fill: '#34302d', 'stroke-width': 1, stroke: '#6db33f', 'follow-scale': true, width: 80, height: 40 },
'text': {
text: '',
fill: '#eeeeee',
'ref-x': 0.5,
'ref-y': 7,
'x-alignment': 'middle',
'font-size': 18/*, 'font-weight': 'bold', 'font-variant': 'small-caps', 'text-transform': 'capitalize'*/
},
'path': { fill: 'white', 'stroke-width': 2, stroke: 'white'/*,transform:'rotate(90,15,15)'*/}
},
// custom properties
isOpen: true
}, joint.shapes.basic.Generic.prototype.defaults)
});
joint.shapes.flo.NoMatchesFound = joint.shapes.basic.Generic.extend({
// The path is the open/close arrow, defaults to vertical (open)
markup: '<g class="scalable"><rect class="no-matches-label-border"/></g><rect class="no-mathes-label-bg"/><text class="no-matches-label"/>',
defaults: joint.util.deepSupplement({
size: {width: 170, height: 30},
position: {x: 0, y: 0},
attrs: {
'.no-matches-label-border': {
refWidth: 1,
refHeight: 1,
refX: 0,
refY: 0,
},
'.no-macthes-label-bg': {
ref: '.no-matches-label',
refWidth: 10,
refHeight: 2,
'follow-scale': true
},
'.no-matches-label': {
text: 'No results found.',
ref: '.no-matches-label-border',
refY: 0.5,
refY2: 5,
yAlignment: 'middle',
},
},
}, joint.shapes.basic.Generic.prototype.defaults)
});
}
export namespace Constants {
export const REMOVE_HANDLE_TYPE = REMOVE;
export const PROPERTIES_HANDLE_TYPE = 'properties';
export const ERROR_DECORATION_KIND = ERROR;
export const PALETTE_CONTEXT = 'palette';
export const CANVAS_CONTEXT = 'canvas';
export const FEEDBACK_CONTEXT = 'feedback';
}
export namespace Shapes {
export interface CreationParams extends Flo.CreationParams {
renderer?: Flo.Renderer;
paper?: dia.Paper;
graph?: dia.Graph;
}
export interface ElementCreationParams extends CreationParams {
position?: dia.Point;
}
export interface LinkCreationParams extends CreationParams {
source: Flo.LinkEnd;
target: Flo.LinkEnd;
}
export interface EmbeddedChildCreationParams extends CreationParams {
parent: dia.Cell;
position?: dia.Point;
}
export interface DecorationCreationParams extends EmbeddedChildCreationParams {
kind: string;
messages: Array<string>;
}
export interface HandleCreationParams extends EmbeddedChildCreationParams {
kind: string;
}
export interface FilterOptions {
amount: number;
[propName: string]: any;
}
export class Factory {
/**
* Create a JointJS node that embeds extra metadata (properties).
*/
static createNode(params: ElementCreationParams): dia.Element {
let renderer = params.renderer;
let paper = params.paper;
let metadata = params.metadata;
let position = params.position;
let props = params.props;
let graph = params.graph || (params.paper ? params.paper.model : undefined);
let node: dia.Element;
if (!position) {
position = {x: 0, y: 0};
}
if (renderer && isFunction(renderer.createNode)) {
node = renderer.createNode({graph, paper}, metadata, props);
} else {
node = new joint.shapes.flo.Node();
if (metadata) {
node.attr('.label/text', metadata.name);
}
}
node.set('type', joint.shapes.flo.NODE_TYPE);
if (position) {
node.set('position', position);
}
if (props) {
Array.from(props.keys()).forEach(key => node.attr(`props/${key}`, props!.get(key)));
}
node.set('metadata', metadata);
if (graph) {
graph.addCell(node);
}
if (renderer && isFunction(renderer.initializeNewNode)) {
let descriptor: Flo.ViewerDescriptor = {
paper: paper,
graph: graph
};
renderer.initializeNewNode(node, descriptor);
}
return node;
}
static createLink(params: LinkCreationParams): dia.Link {
let renderer = params.renderer;
let paper = params.paper;
let metadata = params.metadata;
let source = params.source;
let target = params.target;
let props = params.props;
let graph = params.graph || (params.paper ? params.paper.model : undefined);
let link: dia.Link;
if (renderer && isFunction(renderer.createLink)) {
link = renderer.createLink(source, target, metadata, props);
} else {
link = new joint.shapes.flo.Link();
}
if (source) {
link.set('source', source);
}
if (target) {
link.set('target', target);
}
link.set('type', joint.shapes.flo.LINK_TYPE);
if (metadata) {
link.set('metadata', metadata);
}
if (props) {
Array.from(props.keys()).forEach(key => link.attr(`props/${key}`, props!.get(key)));
}
if (graph) {
graph.addCell(link);
}
if (renderer && isFunction(renderer.initializeNewLink)) {
let descriptor: Flo.ViewerDescriptor = {
paper: paper,
graph: graph
};
renderer.initializeNewLink(link, descriptor);
}
// prevent creation of link breaks
link.attr('.marker-vertices/display', 'none');
return link;
}
static createDecoration(params: DecorationCreationParams): dia.Element {
let renderer = params.renderer;
let paper = params.paper;
let parent = params.parent;
let kind = params.kind;
let messages = params.messages;
let location = params.position;
let graph = params.graph || (params.paper ? params.paper.model : undefined);
let decoration: dia.Element;
if (renderer && isFunction(renderer.createDecoration)) {
decoration = renderer.createDecoration(kind, parent);
}
if (decoration) {
decoration.set('type', joint.shapes.flo.DECORATION_TYPE);
if ((isChrome || isFF) && parent && typeof parent.get('z') === 'number') {
decoration.set('z', parent.get('z') + 1);
}
decoration.attr('./kind', kind);
decoration.attr('messages', messages);
if (graph) {
graph.addCell(decoration);
}
parent.embed(decoration);
if (renderer && isFunction(renderer.initializeNewDecoration)) {
let descriptor: Flo.ViewerDescriptor = {
paper: paper,
graph: graph
};
renderer.initializeNewDecoration(decoration, descriptor);
}
return decoration;
}
}
static createHandle(params: HandleCreationParams): dia.Element {
let renderer = params.renderer;
let paper = params.paper;
let parent = params.parent;
let kind = params.kind;
let location = params.position;
let graph = params.graph || (params.paper ? params.paper.model : undefined);
let handle: dia.Element;
if (!location) {
location = {x: 0, y: 0};
}
if (renderer && isFunction(renderer.createHandle)) {
handle = renderer.createHandle(kind, parent);
} else {
handle = new joint.shapes.flo.ErrorDecoration({
size: HANDLE_SIZE,
attrs: {
'image': {
'xlink:href': HANDLE_ICON_MAP.get(kind)
}
}
});
}
handle.set('type', joint.shapes.flo.HANDLE_TYPE);
handle.set('position', location);
if ((isChrome || isFF) && parent && typeof parent.get('z') === 'number') {
handle.set('z', parent.get('z') + 1);
}
handle.attr('./kind', kind);
if (graph) {
graph.addCell(handle);
}
parent.embed(handle);
if (renderer && isFunction(renderer.initializeNewHandle)) {
let descriptor: Flo.ViewerDescriptor = {
paper: paper,
graph: graph
};
renderer.initializeNewHandle(handle, descriptor);
}
return handle;
}
}
}
loadShapes();

8
projects/flo/styles.scss Normal file
View File

@@ -0,0 +1,8 @@
@import "./../../node_modules/jointjs/dist/joint.core.css";
/*
@import "./../../node_modules/codemirror/lib/codemirror.css";
@import "./../../node_modules/codemirror/addon/hint/show-hint.css";
@import "./../../node_modules/codemirror/addon/lint/lint.css";
@import "./../../node_modules/codemirror/addon/scroll/simplescrollbars.css";
*/

View File

@@ -0,0 +1,15 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "../../out-tsc/flo",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"exclude": [
"test.ts",
"**/*.spec.ts"
]
}

View File

@@ -0,0 +1,13 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "./tsconfig.dev.json",
"compilerOptions": {
"declarationMap": false,
"sourceMap": false,
"inlineSourceMap": false,
"inlineSources": false
},
"angularCompilerOptions": {
"compilationMode": "partial"
}
}