345 lines
9.4 KiB
JavaScript
345 lines
9.4 KiB
JavaScript
'use strict';
|
|
|
|
function html(config) {
|
|
const jsonConfig = JSON.stringify(config);
|
|
return String.raw`
|
|
<style>
|
|
.ui_upload {
|
|
height: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
justify-content: space-evenly;
|
|
}
|
|
.ui_upload p.title {
|
|
background: transparent !important;
|
|
font-size: 175%;
|
|
text-align: center;
|
|
width: 99%;
|
|
}
|
|
.ui_upload > progress {
|
|
width: 90%;
|
|
}
|
|
.ui_upload.done > progress {
|
|
display: none;
|
|
}
|
|
.ui_upload > p.result {
|
|
font-size: 150%;
|
|
}
|
|
.ui_upload:not(.done) > p.result {
|
|
display: none;
|
|
}
|
|
.ui_upload > button {
|
|
background: transparent;
|
|
border: 0;
|
|
font-size: xx-large;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
.ui_upload > button[disabled] {
|
|
filter: grayscale(1) brightness(1.5);
|
|
}
|
|
</style>
|
|
|
|
<div id="ui_upload-{{unique}}" class="ui_upload"
|
|
ng-init='init(` + jsonConfig + `)'
|
|
style="width:{{width}}"
|
|
ng-on-dragleave="ondragleave($event)" ng-on-dragenter="ondragenter($event)"
|
|
ng-on-dragover="ondragover($event)" ng-on-drop="ondrop($event)">
|
|
<p class="title">{{title}}</p>
|
|
<progress value="0" max="100"></progress>
|
|
<p class="result">✔️ <small>0s</small></p>
|
|
<input type="file" ng-on-change="onchange($event)" name="ui_upload-filename" accept="{{accept}}" />
|
|
<button class="play" ng-click="playClick($event)" disabled="disabled">▶️</button>
|
|
<button class="stop" ng-click="stopClick($event)" disabled="disabled">⏹️</button>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// NB: This function goes through a toString + eval by Node-RED Dashboard, so no scope
|
|
function initController($scope, events) {
|
|
$scope.init = function (config) {
|
|
$scope.config = config;
|
|
$scope.unique = $scope.$eval('$id');
|
|
$scope.title = config.title || config.name || 'Upload';
|
|
$scope.accept = '' + config.accept;
|
|
$scope.chunkCallback = null;
|
|
$scope.downstreamReady = false;
|
|
// TODO: Find a cleaner approach for the width
|
|
$scope.width = (Math.max(config.width || 12, 4) * 52.5) + 'px';
|
|
};
|
|
|
|
$scope.$watch('msg', function (msg) {
|
|
// Message received from back-end
|
|
if (msg && msg.tick && !$scope.stop && !$scope.pause) {
|
|
if ($scope.chunkCallback) {
|
|
$scope.chunkCallback.f($scope.chunkCallback.e);
|
|
} else {
|
|
$scope.downstreamReady = true;
|
|
}
|
|
}
|
|
});
|
|
|
|
function sendFile(file) {
|
|
const div = document.getElementById('ui_upload-' + $scope.unique);
|
|
div.classList.remove('done');
|
|
const progress = div.querySelector('progress');
|
|
$scope.stop = false;
|
|
$scope.downstreamReady = true;
|
|
|
|
delete $scope.duration;
|
|
const startTime = Date.now() - 1;
|
|
const chunk = 1024 * Math.max($scope.config.chunk || 1024, 1);
|
|
const count = Math.ceil(file.size / chunk);
|
|
const partsId = file.name + ';' + file.size + ';' + Date.now();
|
|
let partsIndex = -1;
|
|
let loaded = 0;
|
|
|
|
let blob;
|
|
const fileReader = new FileReader();
|
|
fileReader.onload = function (e) {
|
|
if ($scope.stop) {
|
|
// Send special paquet to inform the rest of the pipeline
|
|
$scope.send({
|
|
file: {
|
|
lastModified: file.lastModified,
|
|
name: file.name,
|
|
size: file.size,
|
|
type: file.type,
|
|
},
|
|
parts: {
|
|
id: partsId,
|
|
type: 'string',
|
|
ch: '',
|
|
index: partsIndex + 1,
|
|
count: partsIndex + 2,
|
|
chunk: chunk,
|
|
abort: true,
|
|
},
|
|
payload: '',
|
|
});
|
|
$scope.stopClick();
|
|
return;
|
|
} else if (!$scope.chunkCallback && ($scope.pause || !$scope.downstreamReady)) {
|
|
$scope.chunkCallback = { f: fileReader.onload, e: e };
|
|
return;
|
|
}
|
|
partsIndex++;
|
|
$scope.chunkCallback = false;
|
|
$scope.downstreamReady = false;
|
|
$scope.send({
|
|
file: {
|
|
lastModified: file.lastModified,
|
|
name: file.name,
|
|
size: file.size,
|
|
type: file.type,
|
|
},
|
|
parts: {
|
|
id: partsId,
|
|
type: 'string',
|
|
ch: '',
|
|
index: partsIndex,
|
|
count: count,
|
|
chunk: chunk,
|
|
},
|
|
payload: e.target.result,
|
|
complete: partsIndex + 1 >= count ? true : undefined,
|
|
});
|
|
loaded += chunk;
|
|
progress.value = 100 * loaded / file.size;
|
|
if (loaded <= file.size) {
|
|
blob = file.slice(loaded, loaded + chunk);
|
|
if ($scope.config.transfer === 'text') {
|
|
fileReader.readAsText(blob, 'Windows-1252');
|
|
} else {
|
|
fileReader.readAsArrayBuffer(blob);
|
|
}
|
|
} else {
|
|
loaded = file.size;
|
|
$scope.duration = Math.ceil((Date.now() - startTime) / 1000);
|
|
$scope.stopClick();
|
|
}
|
|
};
|
|
|
|
blob = file.slice(0, chunk);
|
|
if ($scope.config.transfer === 'text') {
|
|
// NB: Can only be a single-byte encoding / ASCII, so no Unicode / UTF-8!
|
|
fileReader.readAsText(blob, 'Windows-1252');
|
|
} else {
|
|
fileReader.readAsArrayBuffer(blob);
|
|
}
|
|
}
|
|
|
|
let backgroundColor = '';
|
|
|
|
$scope.ondragleave = function (e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const div = e.currentTarget;
|
|
div.style.background = backgroundColor;
|
|
};
|
|
|
|
$scope.ondragenter = function (e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
};
|
|
|
|
$scope.ondragover = function (e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const div = e.currentTarget;
|
|
backgroundColor |= div.style.background;
|
|
div.style.background = '#55E';
|
|
};
|
|
|
|
$scope.ondrop = function (e) {
|
|
const dataTransfer = e.dataTransfer || e.originalEvent.dataTransfer;
|
|
if (dataTransfer && dataTransfer.files && dataTransfer.files.length > 0) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
$scope.stopClick(e);
|
|
const div = e.currentTarget;
|
|
div.style.background = '#5E5';
|
|
setTimeout(function () { div.style.background = backgroundColor; }, 300);
|
|
const input = div.querySelector('input');
|
|
input.files = dataTransfer.files;
|
|
div.querySelector('.play').innerHTML = '⏸️';
|
|
div.querySelector('.play').disabled = false;
|
|
div.querySelector('.stop').disabled = false;
|
|
sendFile(dataTransfer.files[0]);
|
|
}
|
|
};
|
|
|
|
$scope.playClick = function (e) {
|
|
const div = document.getElementById('ui_upload-' + $scope.unique);
|
|
if ($scope.pause) {
|
|
div.querySelector('.play').innerHTML = '⏸️';
|
|
$scope.pause = false;
|
|
$scope.chunkCallback.f($scope.chunkCallback.e);
|
|
} else if (!$scope.stop) {
|
|
$scope.pause = true;
|
|
div.querySelector('.play').innerHTML = '▶️';
|
|
} else {
|
|
$scope.stopClick(e);
|
|
const input = div.querySelector('input');
|
|
if (input.files && input.files.length > 0) {
|
|
div.querySelector('.play').innerHTML = '⏸️';
|
|
div.querySelector('.play').disabled = false;
|
|
div.querySelector('.stop').disabled = false;
|
|
sendFile(input.files[0]);
|
|
}
|
|
}
|
|
};
|
|
|
|
$scope.stopClick = function (e) {
|
|
$scope.stop = true;
|
|
$scope.pause = false;
|
|
$scope.downstreamReady = false;
|
|
$scope.chunkCallback = null;
|
|
const div = document.getElementById('ui_upload-' + $scope.unique);
|
|
div.querySelector('progress').value = 0;
|
|
if ($scope.duration) {
|
|
div.classList.add('done');
|
|
div.querySelector('p.result > small').innerHTML = '' + $scope.duration + 's';
|
|
}
|
|
div.querySelector('.play').innerHTML = '▶️';
|
|
div.querySelector('.play').disabled = false;
|
|
div.querySelector('.stop').disabled = true;
|
|
};
|
|
|
|
$scope.onchange = function (e) {
|
|
$scope.stopClick(e);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Return an incoming node ID if the node has any input wired to it, false otherwise.
|
|
* If filter callback is not null, then this function filters incoming nodes.
|
|
*/
|
|
function findInputNodeId(toNode, filter = null) {
|
|
if (toNode && toNode._flow && toNode._flow.global) {
|
|
const allNodes = toNode._flow.global.allNodes;
|
|
for (const fromNodeId of Object.keys(allNodes)) {
|
|
const fromNode = allNodes[fromNodeId];
|
|
if (fromNode && fromNode.wires) {
|
|
for (const wireId of Object.keys(fromNode.wires)) {
|
|
const wire = fromNode.wires[wireId];
|
|
for (const toNodeId of wire) {
|
|
if (toNode.id === toNodeId && (!filter || filter(fromNode))) {
|
|
return fromNode.id;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
module.exports = function (RED) {
|
|
let ui;
|
|
|
|
function uiUpload(config) {
|
|
const node = this; // jshint ignore:line
|
|
|
|
// Declare the ability of this node to consume ticks from downstream for back-pressure
|
|
node.tickConsumer = true;
|
|
let tickDownstreamId;
|
|
|
|
try {
|
|
if (!ui) {
|
|
// load Dashboard API
|
|
ui = RED.require('node-red-dashboard')(RED);
|
|
}
|
|
|
|
RED.nodes.createNode(node, config);
|
|
if (!config || !config.group) {
|
|
return;
|
|
}
|
|
|
|
// Defined in https://github.com/node-red/node-red-dashboard/blob/39b095586bdbd517ffbce1efff35227283edda4c/index.js
|
|
const done = ui.addWidget({
|
|
node: node,
|
|
format: html(config),
|
|
templateScope: 'local',
|
|
group: config.group,
|
|
order: config.order,
|
|
height: Math.max(config.height || 5, 3),
|
|
emitOnlyNewValues: false,
|
|
forwardInputMessages: false,
|
|
storeFrontEndInputAsState: false,
|
|
persistantFrontEndValue: false,
|
|
|
|
// callback to prepare the message that is emitted to the front-end
|
|
beforeEmit: function (msg, value) {
|
|
return { msg };
|
|
},
|
|
|
|
// callback to prepare the message that is sent to the output
|
|
beforeSend: function (msg, orig) {
|
|
if (tickDownstreamId === undefined) {
|
|
// Search for any output node handling ticks for back-pressure,
|
|
// or any input node (which must take this responsability)
|
|
tickDownstreamId = findInputNodeId(node, (n) => n && n.tickConsumer) || findInputNodeId(node);
|
|
}
|
|
if (!tickDownstreamId) {
|
|
// If there is no tick provider downstream, send default tick for back-pressure
|
|
node.receive({ tick: true });
|
|
}
|
|
if (orig) {
|
|
return orig.msg;
|
|
}
|
|
},
|
|
|
|
// callback to initialize in controller
|
|
initController: initController,
|
|
});
|
|
node.on('close', done);
|
|
} catch (ex) {
|
|
console.error(ex);
|
|
}
|
|
}
|
|
|
|
RED.nodes.registerType('ui_upload', uiUpload);
|
|
};
|