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
JavaScript
/**
* 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 " 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 """ instead of normal quotes. For example:
// <text style="fill:firebrick; font-family: "Arial Black"; 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(/"/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