UNPKG

rpd

Version:

RPD is a minimal framework for building Node-Based User Interfaces, powered by Reactive Programming

820 lines (638 loc) 32.6 kB
;(function(global) { "use strict"; var Rpd = global.Rpd; if (typeof Rpd === "undefined" && typeof require !== "undefined") { Rpd = require('rpd'); } var defaultConfig = { style: 'quartz', // network takes the full page, so the target element will be resized // to match browser window size when it was resized by user and so on fullPage: false, // show inlet/outlet value only when user hovers over its connector // (always showing, by default) valuesOnHover: false, // show node containers for debugging purposes showBoxes: false, // are nodes allowed to be dragged nodeMovingAllowed: true, // only one connection is allowed to inlet by default inletAcceptsMultipleLinks: false, // when user opens a projected sub-patch, automatically close its parent patch closeParent: false, // write global errors to console, if it exists logErrors: true, // a time for value update or error effects on inlets/outlets effectTime: 1000 }; var ƒ = Rpd.unit; var Render = Rpd.Render; // everything common between renderers var δ = Render.data; var tree = { patches: {}, nodes: {}, inlets: {}, outlets: {}, links: {}, patchToPlacing: {}, patchToLinks: {}, nodeToLinks: {} }; var currentPatch; var nodeTypes = Rpd.allNodeTypes, nodeDescriptions = Rpd.allNodeDescriptions, nodeTypeIcons = Rpd.allNodeTypeIcons; function _createSvgElement(name) { return document.createElementNS(d3.namespaces.svg, name); } function SvgRenderer(patch) { return function(networkRoot, userConfig) { var config = mergeConfig(userConfig, defaultConfig); // FIXME: should be called once when renderer is // registered and Rpd.events is ready, not for every patch Render.reportErrorsToConsole(config); var style = Rpd.getStyle(config.style, 'svg')(config); networkRoot = d3.select(networkRoot) .classed('rpd-network', true) .classed('rpd-full-page', config.fullPage); var svg; /* a.k.a. patch canvas, but not obligatory HTML5 canvas */ var connectivity, dnd; return { // the object below reacts on every Patch event and constructs corresponding // HTML structures in response, or modifies them; 'patch/is-ready': function(update) { var docElm = d3.select(document.documentElement); // build canvas element as a target for all further patch modifications svg = d3.select(_createSvgElement('svg')) .attr('width', networkRoot.property('clientWidth')) .attr('height', networkRoot.property('clientHeight')) .classed('rpd-canvas', true); svg.append('rect').attr('class', 'rpd-background'); if (config.fullPage) { svg.attr('width', '100%'/*docElm.property('clientWidth')*/) .attr('height', docElm.property('clientHeight')); svg.select('.rpd-background').attr('width', '100%'/*docElm.property('clientWidth')*/) .attr('height', docElm.property('clientHeight')); } var patchCanvas = svg.append(ƒ(style.createCanvas(patch, networkRoot.node()).element)) .classed('rpd-style-' + config.style, true) .classed('rpd-values-' + (config.valuesOnHover ? 'on-hover' : 'always-shown'), true) .classed('rpd-show-boxes', config.showBoxes); δ(patchCanvas, update.patch); δ(svg, { canvas: patchCanvas, width: docElm.property('clientWidth'), height: docElm.property('clientHeight'), patch: update.patch }); tree.patches[patch.id] = svg; // resize network root on window resize if (config.fullPage) updateCanvasHeightOnResize(window, document, svg, svg.select('.rpd-background')); // initialize the node placing (helps in determining the position where new node should be located) tree.patchToPlacing[patch.id] = new Render.Placing(style); tree.patchToLinks[patch.id] = new VLinks(); // initialize connectivity module, it listens for clicks on outlets and inlets and builds or removes // links if they were clicked in the appropriate order connectivity = new /*Render.*/Connectivity(svg, style, config); // initialized drag-n-drop support (used to allow user drag nodes) if (config.nodeMovingAllowed) dnd = new Render.DragAndDrop(svg, style); Kefir.fromEvents(svg.node(), 'selectstart').onValue(preventDefault); }, 'patch/open': function(update) { if ((config.closeParent || config.fullPage) && update.parent) update.parent.close(); currentPatch = update.patch; var newCanvas = tree.patches[update.patch.id]; networkRoot.append(ƒ(newCanvas.node())); tree.patchToLinks[update.patch.id].updateAll(); if (style.onPatchSwitch) style.onPatchSwitch(currentPatch, newCanvas.node()); }, 'patch/close': function(update) { currentPatch = null; svg.remove(); }, 'patch/refer': function(update) { var node = update.node; var nodeBox = tree.nodes[node.id]; nodeBox.select('.rpd-node').classed('rpd-patch-reference', true); δ(nodeBox).processTarget.append(ƒ(_createSvgElement('text'))) .text('[' + (update.target.name || update.target.id) + ']'); // add the ability to enter the patch by clicking node body (TODO: move to special node type) Kefir.fromEvents(δ(nodeBox).processTarget.node(), 'click') .onValue((function(current, target) { return function() { target.open(current); } })(patch, update.target)); }, 'patch/move-canvas': function(update) { svg.style('position', 'relative'); svg.style('top', Math.floor(update.position[0]) + 'px'); svg.style('left', Math.floor(update.position[1]) + 'px'); //svg.attr('transform', 'translate(' + Math.floor(update.position[0]) + ',' + Math.floor(update.position[1]) + ')'); }, 'patch/resize-canvas': function(update) { svg.attr('width', update.size[0]); svg.attr('height', update.size[1]); svg.select('.rpd-background').attr('width', '100%') .attr('height', '100%'); }, 'patch/add-node': function(update) { var node = update.node; var render = update.render; // find a rectange to place the new node var placing = tree.patchToPlacing[update.patch.id], // current patch canvas should be used as a limit source, even if we add to another patch // or else other canvas may have no dimensions yet limitSrc = δ(tree.patches[currentPatch.id]); var nodeBox = d3.select(_createSvgElement('g')).attr('class', 'rpd-node-box'); var styledNode = style.createNode(node, render, nodeDescriptions[node.type], nodeTypeIcons[node.type]); var nodeElm = nodeBox.append(ƒ(styledNode.element)); δ(nodeBox, { inletsTarget: nodeElm.select('.rpd-inlets'), outletsTarget: nodeElm.select('.rpd-outlets'), processTarget: nodeElm.select('.rpd-process'), position: nodePos, size: styledNode.size }); tree.nodes[node.id] = nodeBox; var nodePos = placing.nextPosition(node, styledNode.size, { width: limitSrc.width, height: limitSrc.height }); node.move(nodePos.x, nodePos.y); var nodeLinks = new VLinks(); tree.nodeToLinks[node.id] = nodeLinks; // add possiblity to drag nodes if (config.nodeMovingAllowed) { var shadow = nodeElm.select('.rpd-shadow'), handle = nodeElm.select('.rpd-drag-handle'); if (!handle.empty()) { dnd.add(handle, { start: function() { nodeElm.classed('rpd-dragging', true); if (!shadow.empty()) shadow.attr('x', 7).attr('y', 8); return δ(nodeBox).position; }, drag: function(pos) { nodeBox.attr('transform', 'translate(' + pos.x + ',' + pos.y + ')'); nodeLinks.forEach(function(vlink) { vlink.update(); }); }, end: function(pos) { node.move(pos.x, pos.y); if (!shadow.empty()) shadow.attr('x', 5).attr('y', 6); nodeElm.classed('rpd-dragging', false); } }); } } // node could require some preparation using patch cavas if (render.prepare) render.prepare.bind(node) (tree.patches[patch.id].node(), tree.patches[currentPatch.id].node()); // use custom node body renderer, if defined if (render.first) subscribeUpdates(node, render.first.bind(node)(nodeElm.select('.rpd-process').node(), node.event['node/configure'])); // if node body should be re-rendered, update links (since body element bounds could change) if (render.always) { // this code used getBoundingClientRect to determine if node body width/height // values were changed and updates links positions only when they really did, // but it appeared to be quite hard to check, i.e. height value, since browsers // keep it equal to 0 node.event['node/process'].throttle(100).onValue(function() { nodeLinks.updateAll(); }); } // remove node when remove button was clicked var removeButton = nodeElm.select('.rpd-remove-button'); if (!removeButton.empty()) { Kefir.fromEvents(nodeElm.select('.rpd-remove-button-handle').node(), 'click') .map(stopPropagation) .onValue(function() { patch.removeNode(node); }); } // append to the the patch canvas node var patchCanvas = δ(tree.patches[node.patch.id]).canvas; patchCanvas.append(ƒ(nodeBox.node())); }, 'patch/remove-node': function(update) { var node = update.node; var nodeBox = tree.nodes[node.id]; tree.nodeToLinks[node.id].forEach(function(vlink) { vlink.get().disconnect(); }); nodeBox.remove(); if (style.onNodeRemove) style.onNodeRemove(node); tree.nodes[node.id] = null; // no updates will fire from this node, // so it's just to avoid holding memory for it tree.nodeToLinks[node.id] = null; }, 'node/move': function(update) { var nodeBox = tree.nodes[update.node.id]; var position = update.position; nodeBox.attr('transform', 'translate(' + Math.floor(position[0]) + ',' + Math.floor(position[1]) + ')'); δ(nodeBox).position = { x: position[0], y: position[1] }; }, 'node/process': function(update) { var node = update.node; var render = update.render; // update node body with custom renderer, if defined if (render.always) { var bodyElm = δ(tree.nodes[node.id]).processTarget.node(); render.always.bind(node)(bodyElm, update.inlets, update.outlets); } }, 'node/add-inlet': function(update) { var inlet = update.inlet; if (inlet.def.hidden) return; var nodeData = δ(tree.nodes[update.node.id]); var inletsTarget = nodeData.inletsTarget; var render = update.render; var inletElm; var inletElm = d3.select(style.createInlet(inlet, render).element); inletElm.classed('rpd-'+inlet.type.replace('/','-'), true) .classed({ 'rpd-stale': true, 'rpd-readonly': inlet.def.readonly || false, 'rpd-cold': inlet.def.cold || false }); var editor = null; if (!inlet.def.readonly && render.edit) { editor = new ValueEditor(inlet, render, svg, inletElm.select('.rpd-value-holder'), inletElm.select('.rpd-value'), d3.select(_createSvgElement('g'))); inletElm.select('.rpd-value-holder').append(ƒ(editor.editorElm.node())); } δ(inletElm, { connector: inletElm.select('.rpd-connector'), value: inletElm.select('.rpd-value'), vlinks: new VLinks(), // links associated with this inlet editor: editor //position: inletPos }); tree.inlets[inlet.id] = inletElm; // adds `rpd-error` CSS class and removes it by timeout inlet.event['inlet/update'].onError(function() { addValueErrorEffect(inlet.id, inletElm, config.effectTime); }); // listen for clicks in connector and allow to edit links this way connectivity.subscribeInlet(inlet, inletElm.select('.rpd-connector')); inletsTarget.append(ƒ(inletElm.node())); }, 'node/add-outlet': function(update) { var outlet = update.outlet; var nodeData = δ(tree.nodes[update.node.id]); var outletsTarget = nodeData.outletsTarget; var render = update.render; var outletElm = d3.select(style.createOutlet(outlet, render).element); outletElm.classed('rpd-'+outlet.type.replace('/','-'), true) .classed('rpd-stale', true); δ(outletElm, { connector: outletElm.select('.rpd-connector'), value: outletElm.select('.rpd-value'), vlinks: new VLinks() // links associated with this outlet //position: outletPos }); tree.outlets[outlet.id] = outletElm; // listen for clicks in connector and allow to edit links this way connectivity.subscribeOutlet(outlet, outletElm.select('.rpd-connector')); outletsTarget.append(ƒ(outletElm.node())); }, 'node/remove-inlet': function(update) { var inlet = update.inlet; var inletData = δ(tree.inlets[inlet.id]); inletData.vlinks.forEach(function(vlink) { vlink.get().disconnect(); }); tree.inlets[inlet.id].remove(); if (style.onInletRemove) style.onInletRemove(inlet); tree.inlets[inlet.id] = null; }, 'node/remove-outlet': function(update) { var outlet = update.outlet; var outletData = δ(tree.outlets[outlet.id]); outletData.vlinks.forEach(function(vlink) { vlink.get().disconnect(); }); tree.outlets[outlet.id].remove(); if (style.onOutletRemove) style.onOutletRemove(outlet); tree.outlets[outlet.id] = null; }, 'inlet/update': function(update) { var inlet = update.inlet; if (inlet.def.hidden) return; var render = update.render; var inletElm = tree.inlets[inlet.id]; var valueElm = δ(inletElm).value; if (!valueElm.empty()) { var valueRepr = inlet.def.show ? inlet.def.show(update.value) : update.value; if (render.show) { render.show.bind(inlet)(valueElm.node().parentNode || valueElm.node(), update.value, valueRepr); } else { valueElm.text(valueRepr); } } // adds `rpd-fresh` CSS class and removes it by timeout addValueUpdateEffect(inlet.id, inletElm, config.effectTime); }, 'outlet/update': function(update) { var outlet = update.outlet; var render = update.render; var outletElm = tree.outlets[outlet.id]; var valueElm = δ(outletElm).value; if (!valueElm.empty()) { var valueRepr = outlet.def.show ? outlet.def.show(update.value) : update.value; if (render.show) { render.show.bind(outlet)(valueElm.node().parentNode || valueElm.node(), update.value, valueRepr); } else { valueElm.text(valueRepr); } } // adds `rpd-fresh` CSS class and removes it by timeout addValueUpdateEffect(outlet.id, outletElm, config.effectTime); }, 'outlet/connect': function(update) { var link = update.link; var outlet = link.outlet; var inlet = link.inlet; var outletElm = tree.outlets[outlet.id]; var inletElm = tree.inlets[inlet.id]; var outletData = δ(outletElm); var inletData = δ(inletElm); if (!config.inletAcceptsMultipleLinks && (inletData.vlinks.count() === 1)) { throw new Error('Inlet is already connected to a link'); } // disable value editor when connecting to inlet if (inletData.editor) inletData.editor.disable(); var vlink = new VLink(link, style); vlink.construct(config.linkWidth) .rotateOI(outlet, inlet); d3.select(vlink.getElement()) .classed('rpd-'+inlet.type.replace('/','-'), true) .classed('rpd-'+outlet.type.replace('/','-'), true) tree.links[link.id] = vlink; outletData.vlinks.add(vlink); inletData.vlinks.add(vlink); tree.nodeToLinks[outlet.node.id].add(vlink); if (outlet.node.id !== inlet.node.id) { tree.nodeToLinks[inlet.node.id].add(vlink); } tree.patchToLinks[patch.id].add(vlink); vlink.listenForClicks(); vlink.appendTo(svg); }, 'outlet/disconnect': function(update) { var link = update.link; var vlink = tree.links[link.id]; var outlet = link.outlet; var inlet = link.inlet; var outletData = δ(tree.outlets[outlet.id]); var inletData = δ(tree.inlets[inlet.id]); // forget all references tree.links[link.id] = null; outletData.vlinks.remove(vlink); inletData.vlinks.remove(vlink); tree.nodeToLinks[outlet.node.id].remove(vlink); if (outlet.node.id !== inlet.node.id) { tree.nodeToLinks[inlet.node.id].remove(vlink); } tree.patchToLinks[patch.id].remove(vlink); // remove link element vlink.removeFrom(svg); }, 'link/enable': function(update) { var inlet = update.link.inlet; var inletData = δ(tree.inlets[inlet.id]); if (inletData.editor) inletData.editor.disable(); tree.links[update.link.id].enable(); }, 'link/disable': function(update) { tree.links[update.link.id].disable(); } } } // function(target, config) } // function(patch) function patchByHash(tree) { return function(hash) { return δ(tree.patches[hash]).patch; } } function updateCanvasHeightOnResize(_window, _document, svg, background) { // resize canvas element on window resize Kefir.fromEvents(_window, 'resize') .map(function() { return _window.innerHeight || _document.documentElement.clientHeight || _document.body.clientHeight; }) .onValue(function(value) { svg.attr('height', value); background.attr('height', value); δ(svg).height = value; }); δ(svg).subscribedToResize = true; } // ============================================================================= // ============================= Connectivity ================================== // ============================================================================= function awaiting(a, b) { return Kefir.merge([ a.map(ƒ(true)), b.map(ƒ(false)) ]).toProperty(ƒ(false)); } // FRP-based connection (links b/w outlets and inlets) editor logic var Connectivity = (function() { function getLinks(inlet) { return δ(tree.inlets[inlet.id]).vlinks; } function hasLinks(inlet) { return function() { return (getLinks(inlet).count() > 0); } } function getConnector(outlet) { return δ(tree.outlets[outlet.id]).connector; } function removeExistingLink(inletLinks) { if (inletLinks.count() === 1) { // cases when .count() > 1 should never happen in this case var prevLink = inletLinks.getLast().link, otherOutlet = prevLink.outlet; otherOutlet.disconnect(prevLink); } } function removeConnectionsToOutlet(inletLinks, outlet) { inletLinks.forEach(function(vlink) { if (vlink.link.outlet.id === outlet.id) { outlet.disconnect(vlink.link); } }); } function Connectivity(canvas, style, config) { this.canvas = canvas; this.style = style; this.config = config; this.canvasClicks = Kefir.fromEvents(this.canvas.node(), 'click'); this.inletClicks = Kefir.pool(), this.outletClicks = Kefir.pool(); this.startLink = Kefir.emitter(), this.finishLink = Kefir.emitter(), this.doingLink = awaiting(this.startLink, this.finishLink); } Connectivity.prototype.subscribeOutlet = function(outlet, connector) { var canvas = this.canvas; var style = this.style; var config = this.config; var canvasClicks = this.canvasClicks, outletClicks = this.outletClicks, inletClicks = this.inletClicks; var startLink = this.startLink, finishLink = this.finishLink, doingLink = this.doingLink; // - Every time user clicks an outlet, a new link is created which user can drag, then: // - If user clicks other outlet after that, linking process is cancelled; // - If user clicks canvas element, linking process is cancelled; // - If user clicks an inlet, linking process is considered successful and finished, but also... // - If this inlet had a link there connected, this previous link is removed and disconnected; outletClicks.plug(Kefir.fromEvents(connector.node(), 'click') .map(extractPos) .map(addTarget(outlet))); Kefir.fromEvents(connector.node(), 'click') .map(stopPropagation) .filterBy(awaiting(outletClicks, doingLink)) .map(extractPos) .onValue(function(pos) { startLink.emit(); var ghost = new VLink(null, style).construct(config.linkWidth) .rotateO(outlet, pos.x, pos.y) .noPointerEvents().appendTo(canvas); d3.select(ghost.getElement()) .classed('rpd-'+outlet.type.replace('/','-'), true); Kefir.fromEvents(canvas.node(), 'mousemove') .takeUntilBy(Kefir.merge([ inletClicks, outletClicks.map(ƒ(false)), canvasClicks.map(ƒ(false)) ]) .take(1) .onValue(function(success) { if (!success) return; var inlet = success.target, inletLinks = getLinks(inlet); if (config.inletAcceptsMultipleLinks) { removeConnectionsToOutlet(inletLinks, outlet); } else { removeExistingLink(inletLinks); } outlet.connect(inlet); })) .map(extractPos) .onValue(function(pos) { ghost.rotateO(outlet, pos.x, pos.y); }).onEnd(function() { ghost.removeFrom(canvas); finishLink.emit(); }); }); }; Connectivity.prototype.subscribeInlet = function(inlet, connector) { var canvas = this.canvas; var style = this.style; var config = this.config; var canvasClicks = this.canvasClicks, outletClicks = this.outletClicks, inletClicks = this.inletClicks; var startLink = this.startLink, finishLink = this.finishLink, doingLink = this.doingLink; // - Every time user clicks an inlet which has a link there connected: // - This link becomes editable and so can be dragged by user, // - If user clicks outlet after that, linking process is cancelled and this link is removed; // - If user clicks canvas element after that, linking process is cancelled, // and this link is removed; // - If user clicks other inlet, the link user drags/edits now is moved to be connected // to this other inlet, instead of first-clicked one; inletClicks.plug(Kefir.fromEvents(connector.node(), 'click') .map(extractPos) .map(addTarget(inlet))); Kefir.fromEvents(connector.node(), 'click') .map(stopPropagation) .filterBy(awaiting(inletClicks, doingLink)) .filter(hasLinks(inlet)) .onValue(function(pos) { var lastLink = getLinks(inlet).getLast().link; var outlet = lastLink.outlet; outlet.disconnect(lastLink); startLink.emit(); var ghost = new VLink(null, style).construct(config.linkWidth) .rotateO(outlet, pos.x, pos.y) .noPointerEvents().appendTo(canvas); d3.select(ghost.getElement()) .classed('rpd-'+inlet.type.replace('/','-'), true) .classed('rpd-'+outlet.type.replace('/','-'), true); Kefir.fromEvents(canvas.node(), 'mousemove') .takeUntilBy(Kefir.merge([ inletClicks, outletClicks.map(ƒ(false)), canvasClicks.map(ƒ(false)) ]) .take(1) .onValue(function(success) { if (!success) return; var otherInlet = success.target, otherInletLinks = getLinks(otherInlet); if (config.inletAcceptsMultipleLinks) { removeConnectionsToOutlet(otherInletLinks, outlet); } else { removeExistingLink(otherInletLinks); } outlet.connect(otherInlet); })) .map(extractPos) .onValue(function(pos) { ghost.rotateO(outlet, pos.x, pos.y); }).onEnd(function() { ghost.removeFrom(canvas); finishLink.emit(); }); }); }; return Connectivity; })(); // ============================================================================= // =============================== Values ====================================== // ============================================================================= function ValueEditor(inlet, render, canvas, valueHolder, valueElm, editorElm) { var valueIn = Kefir.emitter(), disableEditor = Kefir.emitter(); this.disableEditor = disableEditor; this.editorElm = editorElm; this.valueElm = valueElm; editorElm.classed('rpd-value-editor', true); var valueOut = render.edit.bind(inlet)(editorElm.node(), inlet, valueIn); valueOut.onValue(function(value) { inlet.receive(value); }); Kefir.combine([ Kefir.merge([ Kefir.fromEvents(valueHolder.node(), 'click') .map(stopPropagation) .map(ƒ(true)), Kefir.fromEvents(canvas.node(), 'click') .merge(disableEditor) .map(ƒ(false)) ]) .toProperty(ƒ(false)) .skipDuplicates() ], [ inlet.event['inlet/update'] ]) .map(function(val) { return { lastValue: val[1], startEditing: val[0], cancelEditing: !val[0] }; }) .onValue(function(conf) { if (conf.startEditing) { var inletData = δ(tree.inlets[inlet.id]); if (inletData.link) inletData.link.disable(); valueIn.emit(conf.lastValue); valueHolder.classed('rpd-editor-enabled', true); valueHolder.classed('rpd-editor-disabled', false); } else if (conf.cancelEditing) { valueElm.classed('rpd-edited', true); valueHolder.classed('rpd-editor-enabled', false); valueHolder.classed('rpd-editor-disabled', true); } }); valueHolder.classed('rpd-editor-enabled', false); valueHolder.classed('rpd-editor-disabled', true); } ValueEditor.prototype.disable = function() { this.valueElm.classed('rpd-edited', false); this.disableEditor.emit(); } // ============================================================================= // =============================== helpers ===================================== // ============================================================================= var VLink = Render.VLink, VLinks = Render.VLinks; var mergeConfig = Render.mergeConfig; var preventDefault = Render.preventDefault, stopPropagation = Render.stopPropagation; var extractPos = Render.extractPos; var addTarget = Render.addTarget, addClickSwitch = Render.addClickSwitch; var addValueErrorEffect = Render.addValueErrorEffect, addValueUpdateEffect = Render.addValueUpdateEffect; var subscribeUpdates = Render.subscribeUpdates; // ============================================================================= // ============================ registration =================================== // ============================================================================= Rpd.SvgRenderer = SvgRenderer; Rpd.renderer('svg', SvgRenderer); })(this);