UNPKG

node-red-contrib-ui-svg

Version:

A Node-RED widget node to show interactive SVG (vector graphics) in the dashboard

801 lines (687 loc) 146 kB
/** * Copyright 2019 Bart Butenaers, Stephen McLaughlin * * 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 * * http://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. **/ module.exports = function(RED) { var settings = RED.settings; const svgUtils = require('./svg_utils'); const fs = require('fs'); const path = require('path'); const mime = require('mime'); const postcss = require('postcss'); const prefixer = require('postcss-prefix-selector'); const svgParser = require('svgson') // Shared object between N instances of this node (caching for performance) var faMapping; // ------------------------------------------------------------------------------------------------- // Determining the path to the files in the dependent js-beautify module once. // See https://discourse.nodered.org/t/use-files-from-dependent-npm-module/17978/5?u=bartbutenaers // ------------------------------------------------------------------------------------------------- var jsBeautifyHtmlPath = require.resolve("js-beautify"); // For example suppose the require.resolved results in jsBeautifyHtmlPath = /home/pi/.node-red/node_modules/js-beautify/js/index.js jsBeautifyHtmlPath = jsBeautifyHtmlPath.replace("index.js", "lib" + path.sep + "beautify-html.js"); if (!fs.existsSync(jsBeautifyHtmlPath)) { console.log("Javascript file " + jsBeautifyHtmlPath + " does not exist"); jsBeautifyHtmlPath = null; } // ------------------------------------------------------------------------------------------------- // Determining the path to the files in the dependent js-beautify module once. // See https://discourse.nodered.org/t/use-files-from-dependent-npm-module/17978/5?u=bartbutenaers // ------------------------------------------------------------------------------------------------- var jsBeautifyCssPath = require.resolve("js-beautify"); // For example suppose the require.resolved results in jsBeautifyCssPath = /home/pi/.node-red/node_modules/js-beautify/js/index.js jsBeautifyCssPath = jsBeautifyCssPath.replace("index.js", "lib" + path.sep + "beautify-css.js"); if (!fs.existsSync(jsBeautifyCssPath)) { console.log("Javascript file " + jsBeautifyCssPath + " does not exist"); jsBeautifyCssPath = null; } // ------------------------------------------------------------------------------------------------- // Determining the path to the files in the dependent panzoom module once. // See https://discourse.nodered.org/t/use-files-from-dependent-npm-module/17978/5?u=bartbutenaers // ------------------------------------------------------------------------------------------------- var panzoomPath = require.resolve("@panzoom/panzoom"); // For example suppose the require.resolved results in panzoomPath = /home/pi/.node-red/node_modules/@panzoom/panzoom/dist/panzoom.js // Then we need to load the minified version panzoomPath = panzoomPath.replace("panzoom.js", "panzoom.min.js"); if (!fs.existsSync(panzoomPath)) { console.log("Javascript file " + panzoomPath + " does not exist"); panzoomPath = null; } // ------------------------------------------------------------------------------------------------- // Determining the path to the files in the dependent hammerjs module once. // See https://discourse.nodered.org/t/use-files-from-dependent-npm-module/17978/5?u=bartbutenaers // ------------------------------------------------------------------------------------------------- var hammerPath = require.resolve("hammerjs"); // For example suppose the require.resolved results in panzoomPath = /home/pi/.node-red/node_modules/hammerjs/hammer.js // Then we need to load the minified version hammerPath = hammerPath.replace("hammer.js", "hammer.min.js"); if (!fs.existsSync(hammerPath)) { console.log("Javascript file " + hammerPath + " does not exist"); hammerPath = null; } function HTML(config) { // The old node id's in Node-RED contained a dot. However that dot causes problems when using it inside scopedCssString. // Because the CSS will interpret the dot incorrectly, and will not apply the styles to the requested elements. // Therefore we will replace the dot by an underscore. config.nodeIdWithoutDot = config.id.replace(".", "_"); // The configuration is a Javascript object, which needs to be converted to a JSON string var configAsJson = JSON.stringify(config, function (key,value) { switch (key) { case "svgString": // Make sure the config.svgString value is not serialized in the JSON string because: // - that field is already being passed as innerHtml for the SVG element. // - that field would be passed unchanged to the client, while the same svgString would be changed for the innerHtml // (e.g. when the &quot; would still be available in the configAsJson, then the AngularJs client would still give parser errors). // - for performance is it useless to send the same SVG string twice to the client, where it is never used via configAsJson return undefined; case "sourceCode": // Encode the javascript event handling source code as base64, otherwise AngularJs will not be able to parse it (due to unmatched quotes...) return new Buffer(value).toString('base64'); } return value; }); // Fill the map once if (!faMapping) { faMapping = svgUtils.getFaMapping(); } var svgString = config.svgString; // When no SVG string has been specified, we will show a notification if (!svgString || svgString === "") { svgString = String.raw`<svg width="250" height="100" xmlns="http://www.w3.org/2000/svg"> <g> <rect stroke="#000000" id="svg_2" height="50" width="200" y="2.73322" x="2.00563" stroke-width="5" fill="#ff0000"/> <text font-weight="bold" stroke="#000000" xml:space="preserve" text-anchor="middle" font-family="Sans-serif" font-size="24" id="svg_1" y="35.85669" x="100" stroke-width="0" fill="#000000">SVG is empty</text> </g> </svg>`; } // When a text element contains the CSS classname of a FontAwesome icon, we will replace it by its unicode value. svgString = svgString.replace(/(<text.*>)(.*)(<\/text>)/g, function(match, $1, $2, $3, offset, input_string) { var iconCssClass = $2.trim(); if (!iconCssClass.startsWith("fa-")) { // Nothing to replace when not a FontAwesome icon, so return the original text return match; } var uniCode = faMapping.get(iconCssClass); if (!uniCode) { // Failed to get the unicode value of the specified icon, so return the original text console.log("FontAwesome icon " + iconCssClass + " is not supported by this node"); return match; } // Replace the CSS class name ($2) by its unicode value return $1 + "&#x" + uniCode + ";" + $3; }) // When the SVG string contains links to local server image files, we will replace those by a data url containing the base64 encoded // string of that image. Otherwise the Dashboard (i.e. the browser) would not have access to that image... We could have also added // an extra httpNode endpoint for the dashboard, which could provided images (similar to the admin endpoint which is available for the // flow editor. This function is very similar to the function (in the html file) for resolving local images for DrawSvg... svgString = svgString.replace(/(xlink:href="[\\\/]{1})([^"]*)(")/g, function(match, $1, $2, $3, offset, input_string) { if (!config.directory) { console.log("For svg node with id=" + config.id + " no image directory has been specified"); // Leave the local file path untouched, since we cannot load the specified image return $1 + $2 + $3; } // This is the local file URL, without the (back)slash in front var relativeFilePath = $2; var absoluteFilePath = path.join(config.directory, relativeFilePath); if (!fs.existsSync(absoluteFilePath)) { console.log("The specified local image file (" + absoluteFilePath + ") does not exist"); // Leave the local file path untouched, since we cannot load the specified image return $1 + $2 + $3; } try { var data = fs.readFileSync(absoluteFilePath); var contentType = mime.getType(absoluteFilePath); var buff = new Buffer(data); var base64data = buff.toString('base64'); // Return data url of base64 encoded image string return 'xlink:href="data:' + contentType + ';base64,' + base64data + '"'; } catch (err) { console.log("Cannot read the specified local image file (" + absoluteFilePath + "): " + err); // Leave the local file path untouched, since we cannot load the specified image return $1 + $2 + $3; } }) // Seems that the SVG string sometimes contains "&quot;" instead of normal quotes. For example: // <text style="fill:firebrick; font-family: &quot;Arial Black&quot;; font-size: 50pt;" // Those need to be removed, otherwise AngularJs will throw a parse error. // Since those seem to occur between the double quotes of an attribute value, we will remove them (instead of replacing by single quotes) svgString = svgString.replace(/&quot;/g, ""); // Migrate old nodes which don't have pan/zoom functionality yet var panning = config.panning || "disabled"; var zooming = config.zooming || "disabled"; var panzoomScripts = ""; if (panning !== "disabled" || zooming !== "disabled") { panzoomScripts = String.raw`<script src= "ui_svg_graphics/lib/panzoom"></script> <script src= "ui_svg_graphics/lib/hammer"></script>` } const DEFAULT_CSS_SVG_NODE = `div.ui-svg svg{ color: var(--nr-dashboard-widgetColor); fill: currentColor !important; } div.ui-svg path { fill: inherit; }`; // Apply a default CSS string to older nodes (version 2.2.4 and below) var cssString = config.cssString || DEFAULT_CSS_SVG_NODE; // Create a scoped CSS string, i.e. CSS styles that are only applied to the SVG in this node. // However scoped css has been removed from the specs (see https://github.com/whatwg/html/issues/552). // As a workaround we apply a prefix to every css selector, to make sure it is only applied to this SVG. // A new outer div has been added with a unique class, to make prefixing easier. const scopedCssString = postcss().use(prefixer({ prefix: ".svggraphics_" + config.nodeIdWithoutDot })).process(cssString).css; var html = String.raw `<style>` + scopedCssString + `</style>` + panzoomScripts + `<div id='tooltip_` + config.nodeIdWithoutDot + `' display='none' style='z-index: 9999; position: absolute; display: none; background: cornsilk; border: 1px solid black; border-radius: 5px; padding: 2px;'> </div> <div class='svggraphics_` + config.nodeIdWithoutDot + `' style="width:100%; height:100%;"> <div class='ui-svg' id='svggraphics_` + config.nodeIdWithoutDot + `' ng-init='init(` + configAsJson + `)' style="width:100%; height:100%;">` + svgString + ` </div> </div>`; return html; }; function checkConfig(node, conf) { if (!conf || !conf.hasOwnProperty("group")) { node.error(RED._("heat-map.error.no-group")); // TODO return false; } return true; } function setResult(msg, field, value) { field = field ? field : "payload"; const keys = field.split('.'); const lastKey = keys.pop(); const lastObj = keys.reduce((obj, key) => obj[key] = obj[key] || {}, msg); lastObj[lastKey] = value; }; function getNestedProperty(obj, key) { // Get property array from key string var properties = key.split("."); // Iterate through properties, returning undefined if object is null or property doesn't exist for (var i = 0; i < properties.length; i++) { if (!obj || !obj.hasOwnProperty(properties[i])) { return undefined; } obj = obj[properties[i]]; } // Nested property found, so return the value return obj; } var ui = undefined; function getAttributeBasedBindings(svgString) { var attributeBasedBindings = []; // Get all values of the custom attributes data-bind-text and data-bind-values. // Don't use matchAll, since that is only available starting from NodeJs version 12.0.0 var regularExpression = /data-bind-[text|values]* *= *"(.*?)"/g; var match; while((match = regularExpression.exec(svgString)) !== null) { // The matched values will contain a "," separated list of msg field names, which need to be stored in an array of msg field names. attributeBasedBindings = attributeBasedBindings.concat(match[1].split(",")); } // Trim the whitespaces from all the field names in the array for (var i = 0; i < attributeBasedBindings.length; i++) { attributeBasedBindings[i] = attributeBasedBindings[i].trim() } // Remove all duplicate msg field names from the array attributeBasedBindings = attributeBasedBindings.filter(function(item,index) { return attributeBasedBindings.indexOf(item) === index; }); return attributeBasedBindings; } function SvgGraphicsNode(config) { try { var node = this; if(ui === undefined) { ui = RED.require("node-red-dashboard")(RED); } RED.nodes.createNode(this, config); node.outputField = config.outputField; node.bindings = config.bindings; // Get all the attribute based bindings in the svg string (that has been entered in the config screen) node.attributeBasedBindings = getAttributeBasedBindings(config.svgString); // Store the directory property, so it is available in the endpoint below node.directory = config.directory; node.availableCommands = ["get_text", "update_text", "update_innerhtml", "update_style", "set_style", "update_attribute", "set_attribute", "trigger_animation", "add_event", "remove_event", "add_js_event", "remove_js_event", "zoom_in", "zoom_out", "zoom_by_percentage", "zoom_to_level", "pan_to_point", "pan_to_direction", "reset_panzoom", "add_element", "remove_element", "remove_attribute", "get_svg", "replace_svg", "update_value", "replace_attribute", "replace_all_attribute"]; if (checkConfig(node, config)) { var html = HTML(config); var done = ui.addWidget({ node: node, group: config.group, order: config.order, width: config.width, height: config.height, format: html, templateScope: "local", emitOnlyNewValues: false, forwardInputMessages: false, storeFrontEndInputAsState: false, // Avoid contextmenu to appear automatically after deploy. // (see https://github.com/node-red/node-red-dashboard/pull/558) persistantFrontEndValue: false, convertBack: function (value) { return value; }, beforeEmit: function(msg, value) { // ****************************************************************************************** // Server side validation of input messages. // ****************************************************************************************** // Would like to ignore invalid input messages, but that seems not to possible in UI nodes: // See https://discourse.nodered.org/t/custom-ui-node-not-visible-in-dashboard-sidebar/9666 // We will workaround it by sending a 'null' payload to the dashboard. if ((msg.enabled === false || msg.enabled === true) && !msg.payload) { // The Node-RED dashboard framework automatically disables/enables all user input when msg.enabled is supplied. // We only need to make sure here the Debug panel is not filled with error messages about missing payloads. // See https://github.com/bartbutenaers/node-red-contrib-ui-svg/issues/124 msg.payload = null; } else if (!msg.payload) { node.error("A msg.payload is required (msg._msgid = '" + msg._msgid + "')"); msg.payload = null; } else { var payload = msg.payload; if(!Array.isArray(payload)){ payload = [payload]; } // Check whether a new svg string is being injected. Only take into account valid svg strings, because otherwise // the svg string will be ignored on the client side. Which means we should also ignore it here on the server-side, // to avoid calculating attribute based bindings for an svg string that is not being used (causing inconsistencies...). var svgReplacements = []; for (var i = 0; i < payload.length; i++) { if (payload[i].command === 'replace_svg' && payload[i].svg) { try { var svgNode = svgParser.parseSync(payload[i].svg); svgReplacements.push(payload[i].svg); } catch (error) { // Do nothing (i.e. the svg will not be appended to svgReplacements) } } } if (svgReplacements.length > 0) { // When a new svg string is injected (to replace the current svg), then all attribute based bindings // should be determined again. See issue https://github.com/bartbutenaers/node-red-contrib-ui-svg/issues/125 node.attributeBasedBindings = getAttributeBasedBindings(svgReplacements[0]); } if(msg.topic == "databind") { // The bindings can be specified both on the config screen and in the SVG source via custom user attributes. // See https://github.com/bartbutenaers/node-red-contrib-ui-svg/issues/67 if (node.bindings.length === 0 && node.attributeBasedBindings.length === 0) { node.error("No bindings have been specified in the config screen or via data-bind-text or via data-bind-values (msg._msgid = '" + msg._msgid + "')"); msg.payload = null; } else { var counter = 0; node.bindings.forEach(function (binding, index) { if (getNestedProperty(msg, binding.bindSource) !== undefined) { counter++; } }); node.attributeBasedBindings.forEach(function (binding, index) { if (getNestedProperty(msg, binding) !== undefined) { counter++; } }); if (counter === 0) { node.error("None of the specified bindings fields (in the config screen or data-bind-text or data-bind-values) are available in this message."); msg.payload = null; } } } else if(msg.topic == "custom_msg") { // no checks } else { if (msg.topic && (typeof payload == "string" || typeof payload == "number")) { var topicParts = msg.topic.split("|"); if (topicParts[0] !== "update_text" || topicParts[0] !== "update_innerHTML") { node.error("Only msg.topic 'update_text' or 'update_innerHTML' is supported (msg._msgid = '" + msg._msgid + "')"); msg.payload = null; } } else { if (msg.topic) { node.warn("The specified msg.topic is not supported"); } if(Array.isArray(msg.payload)){ for (var i = 0; i < msg.payload.length; i++) { var part = msg.payload[i]; if(typeof part === "object" && !part.command) { node.error("The msg.payload array should contain objects which all have a 'command' property (msg._msgid = '" + msg._msgid + "')"); msg.payload = null; break; } // Make sure the commands are not case sensitive anymore if(!node.availableCommands.includes(part.command.toLowerCase())) { node.error("The msg.payload array contains an object that has an unsupported command property '" + part.command + "' (msg._msgid = '" + msg._msgid + "')"); msg.payload = null; break; } } } else { if(typeof msg.payload === "object") { if(!msg.payload.command) { node.error("The msg.payload should contain an object which has a 'command' property (msg._msgid = '" + msg._msgid + "')"); msg.payload = null; } // Make sure the commands are not case sensitive anymore else if(!node.availableCommands.includes(msg.payload.command.toLowerCase())) { node.error("The msg.payload contains an object that has an unsupported command property '" + msg.payload.command + "' (msg._msgid = '" + msg._msgid + "')"); msg.payload = null; } } } } } } return { msg: msg }; }, beforeSend: function (msg, orig) { if (!orig || !orig.msg) { return;//TODO: what to do if empty? Currently, halt flow by returning nothing } // When an error message is being send from the client-side, just log the error if (orig.msg.hasOwnProperty("error")) { node.error(orig.msg.error); // Dirty hack to avoid that the error message is being send on the output of this node orig["_fromInput"] = true; // Legacy code for older dashboard versions orig["_dontSend"] = true; return; } // When an event message is being send from the client-side, just log the event // Bug fix: use "browser_event" instead of "event" because normal message (like e.g. on click) also contain an "event". // See https://github.com/bartbutenaers/node-red-contrib-ui-svg/issues/77 if (orig.msg.hasOwnProperty("browser_event")) { node.warn(orig.msg.browser_event); // Dirty hack to avoid that the event message is being send on the output of this node orig["_fromInput"] = true; // Legacy code for older dashboard versions orig["_dontSend"] = true; return; } // Compose the output message let newMsg = {}; // Copy some fields from the original output message. // Note that those fields are not always available, e.g. when a $scope.send(...) is being called from a javascript event handler. if (orig.msg.topic) { newMsg.topic = orig.msg.topic; } if (orig.msg.elementId) { newMsg.elementId = orig.msg.elementId; } if (orig.msg.selector) { newMsg.selector = orig.msg.selector; } if (orig.msg.event) { newMsg.event = orig.msg.event; } // In the editableList of the clickable shapes, the content of the node.outputField property has been specified. // Apply that content to the node.outputField property in the output message RED.util.evaluateNodeProperty(orig.msg.payload,orig.msg.payloadType,node,orig.msg,(err,value) => { if (err) { return;//TODO: what to do on error? Currently, halt flow by returning nothing } else { setResult(newMsg, node.outputField, value); } }); return newMsg; }, initController: function($scope, events) { // Remark: all client-side functions should be added here! // If added above, it will be server-side functions which are not available at the client-side ... function logError(error) { // Log the error on the client-side in the browser console log console.log(error); // Send the error to the server-side to log it there, if requested if ($scope.config.showBrowserErrors) { $scope.send({error: error}); } } function logEvent(eventDescription) { // Log the eventDescription on the client-side in the browser console log console.log(eventDescription); // Send the event to the server-side to log it there, if requested if ($scope.config.showBrowserEvents) { $scope.send({browser_event: eventDescription}); } } function setTextContent(element, textContent) { var children = []; // When the text contains a FontAwesome icon name, we need to replace it by its unicode value. // This is required when the text content is dynamically changed by a control message. if (typeof textContent == "string" && textContent.startsWith("fa-")) { // Try to get the unicode from our faMapping cache var uniCode = $scope.faMapping[textContent.trim()]; if(uniCode) { textContent = uniCode; } else { // Get the unicode value (that corresponds to the cssClass fa-xxx) from the server-side via a synchronous call $.ajax({ url: "ui_svg_graphics/famapping/" + textContent, dataType: 'json', async: false, success: function(json){ // Only replace the fa-xxx icon when the unicode value is available. if (json.uniCode) { // Cache the unicode mapping on the client-side $scope.faMapping[json.cssClass] = json.uniCode; textContent = json.uniCode; } } }); } } // By setting the text content (which is similar to innerHtml), all animation child elements will be removed. // To solve that we will remove the child elements in advance, and add them again afterwards... children.push(...element.children); for (var i = children.length - 1; i > -1; i--) { element.removeChild(children[i]); } // Cannot use element.textContent because then the FontAwesome icons are not rendered element.innerHTML = textContent; for (var j = 0; j < children.length; j++) { element.appendChild(children[j]); } } function handleEvent(evt, proceedWithoutTimer) { // Uncomment this section to troubleshoot events on mobile devices //function stringifyEvent(e) { // const obj = {}; // for (let k in e) { // obj[k] = e[k]; // } // return JSON.stringify(obj, (k, v) => { // if (v instanceof Node) return 'Node'; // if (v instanceof Window) return 'Window'; // return v; // }, ' '); //} //logError("evt = " + stringifyEvent(evt)); // No need to do this twice: for proceedWithoutTimer=true the click event has already passed here before (with proceedWithoutTimer=null) if (!proceedWithoutTimer) { // PreventDefault to avoid the default browser context menu to popup, in case an event handler has been specfied in this node. // See https://github.com/bartbutenaers/node-red-contrib-ui-svg/pull/93#issue-855852128 evt.preventDefault(); evt.stopPropagation(); logEvent("Event " + evt.type + " has occured"); } // Get the SVG element where the event has occured (e.g. which has been clicked). // Caution: You can add an event handler to a group, which is called when one of the (sub)elements of that group receives that event (e.g. // when that (sub)element is being clicked). The event will bubble from the clicked (sub)element, up until the group element is reached. // At that point we will arrive in this event handler: // - evt.target will refer to the (sub)element that received the event // - evt.currentTarget will refer to the group element to which the event handler is attached. // Since our data-event_xxx attributes are available in the element that has the event handler, we will need to use evt.currentTarget !! // See https://github.com/bartbutenaers/node-red-contrib-ui-svg/issues/97 var svgElement = $(evt.currentTarget)[0]; if (!svgElement) { logError("No SVG element has been found for this " + evt.type + " event"); return; } // When a shape has both a single-click and a double-click event handler. Then a double click will result in in two single click events, // followed by a double click event. See https://discourse.nodered.org/t/node-red-contrib-ui-svg-click-and-dblclick-in-same-element/50203/6?u=bartbutenaers // To prevent the single-clicks from occuring in this case, we start a timer of 400 msec. If more than 1 click event occurs during // that interval, it is considered as a double click (so the single clicks are ignored). if (evt.type == "click" && !proceedWithoutTimer) { // Only do this when this feature has been enabled in the Settings tabsheet if ($scope.config.noClickWhenDblClick) { // Only do this if a double click handler has been registered, to avoid that all click events would become delayed. if (svgElement.hasAttribute("data-event_dblclick")) { if ($scope.clickTimer && $scope.clickTimerTarget != evt.target) { $scope.clickCount = 0; clearTimeout($scope.clickTimer); $scope.clickTimerTarget = null; $scope.clickTimer = null; } $scope.clickCount++; var currentTarget = evt.currentTarget; if (!$scope.clickTimer) { $scope.clickTimerTarget = evt.target; $scope.clickTimer = setTimeout(function() { if ($scope.clickCount < 2) { // The event.currentTarget will only be available during the event handling, and will become null afterwards // (see https://stackoverflow.com/a/66086044). So let's restore it here... // Since currentTarget is a readonly property, it needs to be overwritten via following trick: Object.defineProperty(evt, 'currentTarget', {writable: false, value: currentTarget}); handleEvent(evt, true); } $scope.clickCount = 0; clearTimeout($scope.clickTimer); $scope.clickTimerTarget = null; $scope.clickTimer = null; }, 400); } return; } } } var userData = svgElement.getAttribute("data-event_" + evt.type); if (!userData) { logError("No user data available for this " + evt.type + " event"); return; } userData = JSON.parse(userData); // In version 1.x.x there was a bug (msg.elementId contained the selector instead of the elementId). // This was fixed in version 2.0.0 var msg = { elementId : userData.elementId, selector : userData.selector, payload : userData.payload, payloadType: userData.payloadType, topic : userData.topic } msg.event = { type: evt.type } if (evt.type === "change") { // Get the new value from the target element if (event.target.type === "number") { msg.event.value = event.target.valueAsNumber; } else { msg.event.value = event.target.value; } } else { if (evt.changedTouches) { // For touch events, the coordinates are stored inside the changedTouches field // - touchstart event: list of the touch points that became active with this event (fingers started touching the surface). // - touchmove event: list of the touch points that have changed since the last event. // - touchend event: list of the touch points that have been removed from the surface (fingers no longer touching the surface). var touchEvent = evt.changedTouches[0]; msg.event.pageX = Math.trunc(touchEvent.pageX); msg.event.pageY = Math.trunc(touchEvent.pageY); msg.event.screenX = Math.trunc(touchEvent.screenX); msg.event.screenY = Math.trunc(touchEvent.screenY); msg.event.clientX = Math.trunc(touchEvent.clientX); msg.event.clientY = Math.trunc(touchEvent.clientY); } else { msg.event.pageX = Math.trunc(evt.pageX); msg.event.pageY = Math.trunc(evt.pageY); msg.event.screenX = Math.trunc(evt.screenX); msg.event.screenY = Math.trunc(evt.screenY); msg.event.clientX = Math.trunc(evt.clientX); msg.event.clientY = Math.trunc(evt.clientY); } // Get the mouse coordinates (with origin at left top of the SVG drawing) if(msg.event.pageX !== undefined && msg.event.pageY !== undefined){ var pt = $scope.svg.createSVGPoint(); pt.x = msg.event.pageX; pt.y = msg.event.pageY; pt = pt.matrixTransform($scope.svg.getScreenCTM().inverse()); msg.event.svgX = Math.trunc(pt.x); msg.event.svgY = Math.trunc(pt.y); // Get the SVG element where the event has occured (e.g. which has been clicked) var svgElement = $(evt.target)[0]; if (!svgElement) { logError("No SVG element has been found for this " + evt.type + " event"); return; } var bbox; try { // Use getBoundingClientRect instead of getBBox to have an array like [left, bottom, right, top]. // See https://discourse.nodered.org/t/contextmenu-location/22780/64?u=bartbutenaers bbox = svgElement.getBoundingClientRect(); } catch (err) { logError("No bounding client rect has been found for this " + evt.type + " event"); return; } msg.event.bbox = [ Math.trunc(bbox.left), Math.trunc(bbox.bottom), Math.trunc(bbox.right), Math.trunc(bbox.top) ] } } $scope.send(msg); } function handleJsEvent(evt, proceedWithoutTimer) { // No need to do this twice: for proceedWithoutTimer=true the click event has already passed here before (with proceedWithoutTimer=null) if (!proceedWithoutTimer) { // PreventDefault to avoid the default browser context menu to popup, in case an event handler has been specfied in this node. // See https://github.com/bartbutenaers/node-red-contrib-ui-svg/pull/93#issue-855852128 evt.preventDefault(); evt.stopPropagation(); logEvent("JS event " + evt.type + " has occured"); } // Get the SVG element where the event has occured (e.g. which has been clicked). // Caution: You can add an event handler to a group, which is called when one of the (sub)elements of that group receives that event (e.g. // when that (sub)element is being clicked). The event will bubble from the clicked (sub)element, up until the group element is reached. // At that point we will arrive in this event handler: // - evt.target will refer to the (sub)element that received the event // - evt.currentTarget will refer to the group element to which the event handler is attached. // Since our data-event_xxx attributes are available in the element that has the event handler, we will need to use evt.currentTarget !! // See https://github.com/bartbutenaers/node-red-contrib-ui-svg/issues/97 var svgElement = $(evt.currentTarget)[0]; if (!svgElement) { logError("No SVG element has been found for this " + evt.type + " event"); return; } // When a shape has both a single-click and a double-click event handler. Then a double click will result in in two single click events, // followed by a double click event. See https://discourse.nodered.org/t/node-red-contrib-ui-svg-click-and-dblclick-in-same-element/50203/6?u=bartbutenaers // To prevent the single-clicks from occuring in this case, we start a timer of 400 msec. If more than 1 click event occurs during // that interval, it is considered as a double click (so the single clicks are ignored). if (evt.type == "click" && !proceedWithoutTimer) { // Only do this when this feature has been enabled in the Settings tabsheet if ($scope.config.noClickWhenDblClick) { // Only do this if a double click handler has been registered, to avoid that all click events would become delayed. if (sv