import {mapGetters, mapActions} from 'vuex' import FlowNode from './node' import FlowLink from './link' import FlowTriggerLink from './link-trigger' import FlowPanZoom from './panzoom' import HxContextMenu from '@/components/shared/hx-contextmenu' import SvgDefs from './svgdefswrapper' import utils from '@/utils/utils' export default { name: 'FlowManager', components: {FlowNode, FlowLink, FlowTriggerLink, FlowPanZoom, HxContextMenu, SvgDefs}, props: { 'width': {type: String, default: '800px'}, 'height': {type: String, default: '600px'} }, data () { // const cloned = JSON.parse(JSON.stringify(this.value)) // initial? return { panzoom: { x: 0, y: 0, zoom: 1 }, // Shared state // nodeData: { nodes: [], links: [], triggers: [] }, dragging: null, linking: false, triggerLinking: false, pointerLink: {active: false, props: {}, src: {}}, pointerTriggerLink: {active: false, props: {}, src: {}}, selector: null, nodeSelection: {}, // map of true false stickySockets: false, stickyTriggers: false, nodeActivity: true } }, computed: { ...mapGetters('flow', ['registry', 'activity', 'nodeData', 'nodeById']), outputNode () { const n = this.nodeData.nodes.find(n => n.src === 'Output') return !!n }, viewClasses () { return { 'flow-linking': this.linking || this.stickySockets, 'flow-triggers': this.triggerLinking || this.stickyTriggers, 'activity': this.dragging || this.pointerLink.active, 'flow-node--activity': this.nodeActivity, 'selecting': !!this.selector } }, nodeProps () { return (node) => { let highlight = {} if (this.pointerLink.active && this.pointerLink.src.nodeId !== node.id) { if (this.pointerLink.src.in !== undefined) { highlight = {type: 'socket-out', dtype: this.pointerLink.src.type} } else { highlight = {type: 'socket-in', dtype: this.pointerLink.src.type} } } if (this.pointerTriggerLink.active && this.pointerTriggerLink.src.nodeId !== node.id) { highlight = {type: (this.pointerTriggerLink.src.dir === 'in') ? 'trigger-in' : 'trigger-out'} } const nodeClass = this.registry[node.src] if (!nodeClass) { this.NODE_REMOVE([node]) return } return { transform: `translate(${node.x} ${node.y})`, id: node.id, // Combine this into one match: highlight, dragging: this.dragging && !!this.dragging[node.id], activity: this.activity && this.activity.nodes && this.activity.nodes[node.id] } } }, linkProps () { return (link) => { if (!this.$refs.nodes) return // For size .x .y const nodeFrom = this.nodeById(link.from) const nodeTo = this.nodeById(link.to) const refFrom = this.$refs.nodes.find(n => n.id === link.from) const refTo = this.$refs.nodes.find(n => n.id === link.to) if (!refFrom || !refTo) { // delete link return {} } const fromOutput = refFrom.outputPos(0) // only 1 output const toInput = refTo.inputPos(link.in) return { x1: nodeFrom.x + fromOutput.x, y1: nodeFrom.y + fromOutput.y, x2: nodeTo.x + toInput.x, y2: nodeTo.y + toInput.y, status: this.activity.nodes[nodeFrom.id] && this.activity.nodes[nodeFrom.id].status } } }, triggerProps () { return (trigger) => { if (!this.$refs.nodes) return // For size .x .y const nodeFrom = this.nodeById(trigger.from) const nodeTo = this.nodeById(trigger.to) const refFrom = this.$refs.nodes.find(n => n.id === trigger.from) const refTo = this.$refs.nodes.find(n => n.id === trigger.to) if (!refFrom || !refTo) { // delete trigger return {} } const fromOutput = { x: 0, y: refFrom.bodyProps.height / 2 } const toInput = { x: 0, y: -refTo.bodyProps.height / 2 } return { x1: nodeFrom.x + fromOutput.x, y1: nodeFrom.y + fromOutput.y, x2: nodeTo.x + toInput.x, y2: nodeTo.y + toInput.y, status: this.activity.nodes[nodeFrom.id] && this.activity.nodes[nodeFrom.id].status } } } }, mounted () { this.$nextTick(() => { this.$forceUpdate() }) document.addEventListener('keydown', this.keyDown) document.addEventListener('keyup', this.keyUp) }, beoreDestroy () { document.removeEventListener('keydown', this.keyDown) document.removeEventListener('keyup', this.keyUp) }, methods: { ...mapActions('flow', [ 'NOTIFICATION_ADD', 'DOCUMENT_SYNC', 'NODE_RAISE', 'NODE_UPDATE', 'NODE_ADD', 'NODE_REMOVE', 'NODE_INSPECT', 'NODE_PROCESS', 'NODE_TRAIN', 'LINK_ADD', 'LINK_REMOVE', 'TRIGGER_ADD', 'TRIGGER_REMOVE' ]), keyDown (ev) { if (document.activeElement && document.activeElement.matches('input,textarea')) { return } if (ev.shiftKey) { this.linking = true this.triggerLinking = true } let single = null const selectionIds = Object.keys(this.nodeSelection) if (selectionIds.length === 1) { single = this.nodeSelection[selectionIds[0]] } switch (ev.key) { case 'Enter': if (!single) { return } this.nodeInspect(single) break case 'Delete': if (!this.nodeSelection) { return } console.log('Removing nodes:', this.nodeSelection) this.NODE_REMOVE(this.nodeSelection) // for (let k in this.nodeSelection) { // this.nodeRemove(this.nodeSelection[k]) // } break case 'a': if (ev.ctrlKey) { ev.preventDefault() ev.stopPropagation() this.nodeSelection = {} for (let n of this.nodeData.nodes) { this.nodeSelection[n.id] = n } } break } }, keyUp (ev) { if (!ev.shiftKey) { this.linking = false this.triggerLinking = false } }, panzoomReset () { this.panzoom = {x: 0, y: 0, zoom: 1} }, // XXX: Shrink this function // and create some LinkAdd method socketPointerDown (nodeId, ev, socket) { if (ev.button !== 0) return const nodeRef = this.$refs.nodes.find(n => n.id === nodeId) const node = this.nodeById(nodeId) const isInput = socket.in !== undefined const socketPos = isInput ? nodeRef.inputPos(socket.in) : nodeRef.outputPos(socket.out) // const p = this.transformedPoint(ev.clientX, ev.clientY) this.pointerLink.props = { x1: node.x + socketPos.x, y1: node.y + socketPos.y, x2: node.x + socketPos.x, y2: node.y + socketPos.y } this.pointerLink.active = true if (isInput) { this.pointerLink.src = {nodeId: nodeId, type: this.registry[node.src].inputs[socket.in].type, in: socket.in} } else { this.pointerLink.src = {nodeId: nodeId, type: this.registry[node.src].output.type, out: 0} } utils.createDrag({ drag: (ev) => { const p = this.transformedPoint(ev.clientX, ev.clientY) if (isInput) { this.pointerLink.props.x1 = p.x this.pointerLink.props.y1 = p.y } else { this.pointerLink.props.x2 = p.x this.pointerLink.props.y2 = p.y } }, drop: (ev) => { this.pointerLink.active = false // find Parent var curTarget = ev.target for (; curTarget.hasAttribute !== undefined && curTarget !== document.body; curTarget = curTarget.parentNode) { if (curTarget.hasAttribute('data-nodeid')) { break } } if (!curTarget.hasAttribute || curTarget === document.body) { console.error('LINK: target is not a socket') return } const targetNodeId = curTarget.getAttribute('data-nodeid') const targetIn = curTarget.getAttribute('data-in') const targetOut = curTarget.getAttribute('data-out') let link // target is input if (targetIn && !isInput) { link = { from: nodeId, to: targetNodeId, in: parseInt(targetIn) } } else if (targetOut && isInput) { link = { from: targetNodeId, to: nodeId, in: socket.in } } // No link if (!link) { console.error('LINK: input same direction (in->in/out->out)') return } const nodeFrom = this.nodeById(link.from) const nodeTo = this.nodeById(link.to) const output = this.registry[nodeFrom.src].output.type const input = this.registry[nodeTo.src].inputs[link.in].type // Type checking if (!(output === 'interface {}' || output === input || input === 'interface {}')) { console.error('LINK: Invalid type') return } // Input already exists, replace const existingInput = this.nodeData.links.find(l => l.to === link.to && l.in === link.in) if (existingInput) { // REMOVE LINK this.LINK_REMOVE(existingInput) } this.LINK_ADD(link) }}) }, triggerPointerDown (nodeId, ev, dir) { if (ev.button !== 0) return const nodeRef = this.$refs.nodes.find(n => n.id === nodeId) const node = this.nodeById(nodeId) const isInput = (dir === 'in') const triggerPos = { x: 0, y: nodeRef.bodyProps.height / 2 * (isInput ? -1 : 1) } this.pointerTriggerLink.src = {nodeId: node.id, dir: dir} this.pointerTriggerLink.props = { x1: node.x + triggerPos.x, y1: node.y + triggerPos.y, x2: node.x + triggerPos.x, y2: node.y + triggerPos.y } this.pointerTriggerLink.active = true utils.createDrag({ drag: (ev) => { const p = this.transformedPoint(ev.clientX, ev.clientY) if (isInput) { this.pointerTriggerLink.props.x1 = p.x this.pointerTriggerLink.props.y1 = p.y } else { this.pointerTriggerLink.props.x2 = p.x this.pointerTriggerLink.props.y2 = p.y } }, drop: (ev) => { this.pointerTriggerLink.active = false // find Parent var curTarget = ev.target for (; curTarget.hasAttribute !== undefined && curTarget !== document.body; curTarget = curTarget.parentNode) { if (curTarget.hasAttribute('data-nodeid')) { break } } if (!curTarget.hasAttribute || curTarget === document.body) { console.error('TRIGGER: target is not a socket') return } const targetNodeId = curTarget.getAttribute('data-nodeid') const targetDir = curTarget.getAttribute('data-dir') if (targetNodeId === node.id) { console.error('TRIGGER: cannot link to self') return } let trigger // target is input if (targetDir === 'in') { trigger = { from: nodeId, to: targetNodeId } } else { trigger = { from: targetNodeId, to: nodeId } } this.TRIGGER_ADD(trigger) } }) }, nodeInspect (tnode, force) { this.$emit('nodeInspect', tnode, force) }, // HERE nodePointerDown (ev, i) { document.activeElement && document.activeElement.blur() const tnode = this.nodeData.nodes[i] if (ev.button === 1) { this.NODE_REMOVE([tnode]) // this.nodeRemove(tnode) // remove related links return } if (ev.button !== 0) return // first button if (ev.shiftKey) { if (this.registry[tnode.src].output) { this.socketPointerDown(tnode.id, ev, {out: 0}) } return } // this.NODE_INSPECT(tnode.id) this.nodeInspect(tnode) // Switch selection if (!this.nodeSelection[tnode.id] && !ev.ctrlKey) this.nodeSelection = {} this.nodeSelection[tnode.id] = tnode // we can handle with nodeId and a search this.NODE_RAISE(this.nodeSelection) let curP = this.transformedPoint(ev.x, ev.y) this.dragging = this.nodeSelection utils.createDrag({ drag: (ev) => { if (this.nodeSelection === undefined) { console.error('Well something went wrong') } const dragP = this.transformedPoint(ev.x, ev.y) const nodeUpdate = [] for (let k in this.nodeSelection) { // const n = this.nodeData.nodes.find(n => n.id === k) const n = this.nodeById(k) // create new nodes nodeUpdate.push({ ...n, x: n.x + dragP.x - curP.x, y: n.y + dragP.y - curP.y }) } this.NODE_UPDATE(nodeUpdate) // this.sendFlowEvent('nodeUpdate', this.nodeSelection) curP = dragP }, drop: (ev) => { // snap? this.dragging = null // Snapping const dragP = this.transformedPoint(ev.x, ev.y) const nodeUpdate = [] for (let k in this.nodeSelection) { // const n = this.nodeData.nodes.find(n => n.id === k) const n = this.nodeById(k) // create new nodes nodeUpdate.push({ ...n, x: Math.round((n.x + dragP.x - curP.x) / 10) * 10, y: Math.round((n.y + dragP.y - curP.y) / 10) * 10 }) } // Updating nodes this.NODE_UPDATE(nodeUpdate) this.DOCUMENT_SYNC() } }) }, /// //////////// // NODE CREATOR FUNC // // STORE nodeAdd (src, x = 100, y = 100) { if (src === 'Output') { const n = this.nodeData.nodes.find(n => n.src === src) if (n) { this.NOTIFICATION_ADD('Output node already exists') return } } const newNode = { id: utils.guid(), x: x, y: y, defaultInputs: {}, label: src, color: this.registry[src].style && this.registry[src].style.color, /* NEW 12/02/2018 */ src: src } // Setup Props if (this.registry[src].props) { for (let k in this.registry[src].props) { newNode.prop || (newNode.prop = {}) newNode.prop[k] = '' } } this.NODE_ADD(newNode) }, managerDrop (ev) { ev.preventDefault() const reg = ev.dataTransfer.getData('text') if (this.registry[reg] === undefined) { console.error('Registry: Drop src not found in registry') return } const pt = this.transformedPoint(ev.x, ev.y) this.nodeAdd(reg, pt.x, pt.y) }, viewPointerDown (ev) { if (ev.button !== 0) return ev.preventDefault() this.nodeSelection = {} const p = this.transformedPoint(ev.x, ev.y) this.selector = {x: p.x, y: p.y, width: 0, height: 0} utils.createDrag({ drag: (evd) => { // transform again in case we changed zoom/pan const p = this.transformedPoint(ev.x, ev.y) const p2 = this.transformedPoint(evd.x, evd.y) const nwidth = p2.x - p.x const nheight = p2.y - p.y this.selector = { x: nwidth < 0 ? p2.x : p.x, y: nheight < 0 ? p2.y : p.y, width: nwidth < 0 ? -nwidth : nwidth, height: nheight < 0 ? -nheight : nheight } }, drop: (ev) => { for (let n in this.nodeData.nodes) { const node = this.nodeData.nodes[n] if (node.x > this.selector.x && node.x < (this.selector.x + this.selector.width) && node.y > this.selector.y && node.y < (this.selector.y + this.selector.height) ) { this.nodeSelection[node.id] = node } } this.selector = null }}) }, documentProcess () { const n = this.nodeData.nodes.find(n => n.src === 'Output') this.NODE_PROCESS(n.id) }, createPortal (nodeID) { // Find nodeID let node = this.nodeData.nodes.find(n => n.id === nodeID) if (!node) { this.NOTIFICATION_ADD('invalid node ID' + nodeID) return } // Special node const portalNode = { id: utils.guid(), x: node.x + 10, y: node.y + 100, // Downthere/improve this defaultInputs: {}, label: node.label, color: node.color, prop: {'portal from': nodeID}, src: 'Portal From' } this.NODE_ADD(portalNode) }, // HELPERS depending on svg ref createSVGPoint (x, y) { const p = this.$refs.svg.createSVGPoint() p.x = x; p.y = y return p }, transformedPoint (x, y, abs) { const svgRect = this.$refs.svg.getBoundingClientRect() if (!abs) { x -= svgRect.left y -= svgRect.top } return this.$refs.panzoom.transformedPoint(this.createSVGPoint(x, y)) } } }