This repository has been archived by the owner on Aug 10, 2024. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 28
/
svg_graphics.js
2123 lines (1834 loc) · 142 KB
/
svg_graphics.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* 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 (svgElement.hasAttribute("data-js_event_dblclick")) {
if ($scope.clickJsTimer && $scope.clickJsTimerTarget != evt.target) {
$scope.clickJsCount = 0;
clearTimeout($scope.clickJsTimer);
$scope.clickJsTimerTarget = null;
$scope.clickJsTimer = null;
}
$scope.clickJsCount++;
var currentTarget = evt.currentTarget;
if (!$scope.clickJsTimer) {
$scope.clickJsTimerTarget = evt.target;
$scope.clickJsTimer = setTimeout(function() {
if ($scope.clickJsCount < 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});
handleJsEvent(evt, true);
}
$scope.clickJsCount = 0;
clearTimeout($scope.clickJsTimer);
$scope.clickJsTimerTarget = null;
$scope.clickJsTimer = null;
}, 400);
}
return;
}
}
}
var userData = svgElement.getAttribute("data-js_event_" + evt.type);
if (!userData) {
logError("No user data available for this " + evt.type + " javascript event");
return;
}
userData = JSON.parse(userData);
try {
// Make sure the $scope variable is being used once here inside the handleJsEvent function, to make
// sure it becomes available to be used inside the eval expression.
$scope;
if ($scope.config.enableJsDebugging) { debugger; }
// Execute the specified javascript function.
eval(userData.sourceCode || "");
}
catch(err) {
logError("Error in javascript event handler: " + err);
}
}
function applyEventHandlers(rootElement) {
if ($scope.config.clickableShapes) {
// The event handlers that send a message to the server
$scope.config.clickableShapes.forEach(function(clickableShape) {
// CAUTION: The "targetId" now contains the CSS selector (instead of the element id).
// But we cannot rename it anymore in the stored json, since we don't want to have impact on existing flows!!!
// This is only the case for clickable shapes, not for animations (since there is no CSS selector possible)...
if (!clickableShape.targetId) {
return;
}
var elements = rootElement.querySelectorAll(clickableShape.targetId); // The "targetId" now contains the CSS selector!
if (elements.length === 0) {
logError("No elements found for selector '" + clickableShape.targetId + "'");
}
var action = clickableShape.action || "click" ;
elements.forEach(function(element){
// Set a hand-like mouse cursor, to indicate visually that the shape is clickable.
// Don't set the cursor when a cursor with lines is displayed, because then we need to keep
// the crosshair cursor (otherwise the pointer is on top of the tooltip, making it hard to read).
//if (!config.showMouseLines) {
//element.style.cursor = "pointer";
//}
//if the cursor is NOT set and the action is click, set cursor
if(/*!config.showMouseLines && */ action == "click" /*&& !element.style.cursor*/) {
element.style.cursor = "pointer";
}
// Store all the user data in a "data-event_<event>" element attribute, to have it available in the handleEvent function
element.setAttribute("data-event_" + action, JSON.stringify({
elementId : element.id,
selector : clickableShape.targetId, // The "targetId" now contains the CSS selector!
payload : clickableShape.payload,
payloadType: clickableShape.payloadType,
topic : clickableShape.topic
}));
// Make sure we don't end up with multiple handlers for the same event
element.removeEventListener(action, handleEvent, false);
element.addEventListener(action, handleEvent, false);
})
})
}
if ($scope.config.javascriptHandlers) {
// The Javascript event handlers
$scope.config.javascriptHandlers.forEach(function(javascriptHandler) {
// The "msg" event handler will be executed somewhere else (i.e. as a message watch)
if (javascriptHandler.action !== "msg") {
var elements = rootElement.querySelectorAll(javascriptHandler.selector);
if (elements.length === 0) {
logError("No elements found for selector '" + javascriptHandler.selector + "'");
}
var action = javascriptHandler.action || "click" ;
elements.forEach(function(element){
// Set a hand-like mouse cursor, to indicate visually that the shape is clickable.
// Don't set the cursor when a cursor with lines is displayed, because then we need to keep
// the crosshair cursor (otherwise the pointer is on top of the tooltip, making it hard to read).
//if (!config.showMouseLines) {
//element.style.cursor = "pointer";
//}
//if the cursor is NOT set and the action is click, set cursor
if(/*!config.showMouseLines && */ action == "click" /*&& !element.style.cursor*/) {
element.style.cursor = "pointer";
}
// The javascript event handler source code is base64 encoded, so let's decode it.
var sourceCode = atob(javascriptHandler.sourceCode);
// Store the javascript code in a "data-js_event_<event>" element attribute, to have it available in the handleJsEvent function
element.setAttribute("data-js_event_" + action, JSON.stringify({
elementId : element.id,
selector : javascriptHandler.selector,
sourceCode : sourceCode
}));
// Make sure we don't end up with multiple handlers for the same event
element.removeEventListener(action, handleJsEvent, false);
element.addEventListener(action, handleJsEvent, false);
})
}
})
}
}
function initializeSvg(scope) {
$scope.clickCount = 0;
$scope.clickJsCount = 0;
// Make the element clickable in the SVG (i.e. in the DIV subtree), by adding an onclick handler to ALL
// the SVG elements that match the specified CSS selectors.
applyEventHandlers(scope.rootDiv);
// Apply the animations to the SVG elements (i.e. in the DIV subtree), by adding <animation> elements
scope.config.smilAnimations.forEach(function(smilAnimation) {
if (!smilAnimation.targetId) {
return;
}
var element = scope.rootDiv.querySelector("#" + smilAnimation.targetId);
if (element) {
var animationElement;
// For attribute "transform" an animateTransform element should be created
if (smilAnimation.attributeName === "transform") {
animationElement = document.createElementNS("http://www.w3.org/2000/svg", 'animateTransform');
animationElement.setAttribute("type" , smilAnimation.transformType);
}
else {
animationElement = document.createElementNS("http://www.w3.org/2000/svg", 'animate');
}
animationElement.setAttribute("id" , smilAnimation.id);
animationElement.setAttribute("attributeType", "XML"); // TODO what is this used for ???
animationElement.setAttribute("class", smilAnimation.classValue);
animationElement.setAttribute("attributeName", smilAnimation.attributeName);
if(smilAnimation.fromValue != "")
animationElement.setAttribute("from" , smilAnimation.fromValue); //permit transition from current value if not specified
animationElement.setAttribute("to" , smilAnimation.toValue);
animationElement.setAttribute("dur" , smilAnimation.duration + (smilAnimation.durationUnit || "s")); // Seconds e.g. "2s"
if (smilAnimation.repeatCount === "0") {
animationElement.setAttribute("repeatCount" , "indefinite");
}
else {
animationElement.setAttribute("repeatCount" , smilAnimation.repeatCount);
}
if (smilAnimation.end === "freeze") {
animationElement.setAttribute("fill" , "freeze");
}
else {
animationElement.setAttribute("fill" , "remove");