Compare commits
3 commits
ae76368d4d
...
864cf95b8b
Author | SHA1 | Date | |
---|---|---|---|
|
864cf95b8b | ||
|
b90c85f56e | ||
|
98a82b7d6a |
5 changed files with 578 additions and 11 deletions
16
webserver.py
16
webserver.py
|
@ -36,7 +36,7 @@ class WebSocketHandler(tornado.websocket.WebSocketHandler):
|
|||
"""
|
||||
Websocket from the workers
|
||||
"""
|
||||
CORS_ORIGINS = ['localhost']
|
||||
# CORS_ORIGINS = ['localhost']
|
||||
connections = set()
|
||||
|
||||
def initialize(self, config):
|
||||
|
@ -45,11 +45,11 @@ class WebSocketHandler(tornado.websocket.WebSocketHandler):
|
|||
self.hasWritten = False
|
||||
self.dimensions = [None, None]
|
||||
|
||||
def check_origin(self, origin):
|
||||
parsed_origin = urlparse(origin)
|
||||
# parsed_origin.netloc.lower() gives localhost:3333
|
||||
valid = any([parsed_origin.hostname.endswith(origin) for origin in self.CORS_ORIGINS])
|
||||
return valid
|
||||
# def check_origin(self, origin):
|
||||
# parsed_origin = urlparse(origin)
|
||||
# # parsed_origin.netloc.lower() gives localhost:3333
|
||||
# valid = any([parsed_origin.hostname.endswith(origin) for origin in self.CORS_ORIGINS])
|
||||
# return valid
|
||||
|
||||
# the client connected
|
||||
def open(self, p = None):
|
||||
|
@ -127,7 +127,7 @@ class AnimationHandler(tornado.web.RequestHandler):
|
|||
self.set_header("Content-Type", "application/json")
|
||||
# filename = self.get_argument("file", None)
|
||||
if filename == '':
|
||||
names = [f"/files/{name[:-4]}" for name in os.listdir(self.config.storage) if name not in ['.gitignore']]
|
||||
names = sorted([f"/files/{name[:-4]}" for name in os.listdir(self.config.storage) if name not in ['.gitignore']])
|
||||
self.write(json.dumps(names))
|
||||
else:
|
||||
path = os.path.join(self.config.storage,os.path.basename(filename)+".csv")
|
||||
|
@ -257,7 +257,7 @@ if __name__ == "__main__":
|
|||
logger.addHandler(
|
||||
logFileHandler
|
||||
)
|
||||
logger.info("Start server")
|
||||
logger.info(f"Start server: http://localhost:{args.port}")
|
||||
|
||||
server = Server(args, logger)
|
||||
server.start()
|
||||
|
|
119
www/annotate.html
Normal file
119
www/annotate.html
Normal file
|
@ -0,0 +1,119 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" dir="ltr">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Annotate a line animation</title>
|
||||
<style media="screen">
|
||||
#sample,
|
||||
svg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-family: sans-serif;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
img {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
path {
|
||||
fill: none;
|
||||
stroke: gray;
|
||||
stroke-width: 1mm;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
g.before path{
|
||||
opacity: 0.5;
|
||||
stroke: gray !important;
|
||||
}
|
||||
g.after path, path.before_in {
|
||||
opacity: .1;
|
||||
stroke: gray !important;
|
||||
}
|
||||
|
||||
#wrapper {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.gray {
|
||||
position: absolute;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
.playlist {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.playlist li{
|
||||
cursor: pointer;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.playlist li:hover{
|
||||
color: blue;
|
||||
}
|
||||
|
||||
input[type='range']{
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
bottom: 0;
|
||||
left:0;
|
||||
right: 0;width: 90%;
|
||||
}
|
||||
.scrubber{
|
||||
position: absolute !important;
|
||||
z-index: 100;
|
||||
bottom: 30px;
|
||||
left:0;
|
||||
right: 0;width: 90%;
|
||||
}
|
||||
|
||||
.noUi-horizontal .noUi-touch-area{
|
||||
cursor:ew-resize;
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="assets/nouislider-15.5.0.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id='interface'>
|
||||
</div>
|
||||
<script src="assets/nouislider-15.5.0.js"></script>
|
||||
<script src="assets/wNumb-1.2.0.min.js"></script>
|
||||
<script src="annotate.js"></script>
|
||||
<script type='text/javascript'>
|
||||
const player = new Player(document.getElementById("interface"));
|
||||
player.playlist('/files/');
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
446
www/annotate.js
Normal file
446
www/annotate.js
Normal file
|
@ -0,0 +1,446 @@
|
|||
class Annotation{
|
||||
constructor(annotation, t_in, t_out) {
|
||||
this.annotation = annotation;
|
||||
this.t_in = t_in;
|
||||
this.t_out = t_out;
|
||||
}
|
||||
}
|
||||
|
||||
class StrokeGroup{
|
||||
constructor(group_element, player){
|
||||
this.g = group_element;
|
||||
this.player = player;
|
||||
}
|
||||
|
||||
setStrokes(strokes){
|
||||
console.log('set strokes',strokes);
|
||||
const pathEls = this.g.querySelectorAll('path');
|
||||
let indexes = Object.keys(strokes);
|
||||
for (let pathEl of pathEls) {
|
||||
const i = pathEl.dataset.path_i;
|
||||
if(!indexes.includes(pathEl.dataset.path_i)){
|
||||
pathEl.parentNode.removeChild(pathEl);
|
||||
}else{
|
||||
// check in and outpoint using pathEl.dataset
|
||||
if(strokes[i].getSliceId() != pathEl.dataset.slice){
|
||||
const d = this.points2D(strokes[i].points);
|
||||
pathEl.dataset.slice = strokes[i].getSliceId();
|
||||
pathEl.setAttribute('d', d);
|
||||
}
|
||||
}
|
||||
|
||||
// this has now been processed
|
||||
indexes.splice(indexes.indexOf(i), 1);
|
||||
}
|
||||
console.log(indexes);
|
||||
|
||||
// new strokes
|
||||
indexes.forEach(index => {
|
||||
const stroke = strokes[index];
|
||||
|
||||
let pathEl = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
pathEl.style.stroke = stroke.color;
|
||||
pathEl.classList.add('path');
|
||||
pathEl.dataset.path_i = index;
|
||||
pathEl.dataset.slice = stroke.getSliceId();
|
||||
this.g.appendChild(pathEl);
|
||||
|
||||
const d = this.points2D(stroke.points);
|
||||
pathEl.setAttribute('d', d);
|
||||
});
|
||||
}
|
||||
|
||||
// convert array of points to a d-attribute
|
||||
points2D(strokes) {
|
||||
// strokes to a d attribute for a path
|
||||
let d = "";
|
||||
let last_stroke = undefined;
|
||||
let cmd = "";
|
||||
for (let stroke of strokes) {
|
||||
if (!last_stroke) {
|
||||
d += `M${stroke[0] * this.player.dimensions[0]},${stroke[1] * this.player.dimensions[1]} `;
|
||||
cmd = 'M';
|
||||
} else {
|
||||
if (last_stroke[2] == 1) {
|
||||
d += " m";
|
||||
cmd = 'm';
|
||||
} else if (cmd != 'l') {
|
||||
d += ' l ';
|
||||
cmd = 'l';
|
||||
}
|
||||
let rel_stroke = [stroke[0] - last_stroke[0], stroke[1] - last_stroke[1]];
|
||||
d += `${rel_stroke[0] * this.player.dimensions[0]},${rel_stroke[1] * this.player.dimensions[1]} `;
|
||||
}
|
||||
last_stroke = stroke;
|
||||
|
||||
}
|
||||
return d;
|
||||
}
|
||||
}
|
||||
|
||||
class Stroke{
|
||||
constructor(color, points){
|
||||
this.color = color;
|
||||
this.points = points; // [[x1,y1,t1], [x2,y2,t2], ...]
|
||||
}
|
||||
|
||||
getSliceId() {
|
||||
return 'all';
|
||||
}
|
||||
}
|
||||
|
||||
class StrokeSlice{
|
||||
constructor(stroke, i_in, i_out){
|
||||
this.stroke = stroke; // Stroke
|
||||
this.i_in = typeof i_in === 'undefined' ? 0 : i_in;
|
||||
this.i_out = typeof i_out === 'undefined' ? this.stroke.points.length : i_out;
|
||||
}
|
||||
|
||||
getSliceId(){
|
||||
return `${this.i_in}-${this.i_out}`;
|
||||
}
|
||||
|
||||
// compatible with Stroke()
|
||||
get points(){
|
||||
return this.stroke.points.slice(this.i_in, this.i_out);
|
||||
}
|
||||
|
||||
// compatible with Stroke()
|
||||
get color(){
|
||||
return this.stroke.color;
|
||||
}
|
||||
}
|
||||
|
||||
class Player {
|
||||
constructor(wrapperEl) {
|
||||
this.wrapperEl = wrapperEl;
|
||||
this.svgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
this.wrapperEl.appendChild(this.svgEl);
|
||||
|
||||
this.scrubberElOld = document.createElement('input');
|
||||
this.scrubberElOld.type = "range";
|
||||
this.scrubberElOld.min = 0;
|
||||
this.scrubberElOld.step = 0.01;
|
||||
this.wrapperEl.appendChild(this.scrubberElOld);
|
||||
|
||||
this.scrubberEl = document.createElement('div');
|
||||
this.scrubberEl.classList.add('scrubber')
|
||||
this.wrapperEl.appendChild(this.scrubberEl);
|
||||
|
||||
this.scrubberElOld.addEventListener("input", (ev) => {
|
||||
this.scrubTo(ev.target.value);
|
||||
})
|
||||
|
||||
this.inPointPosition = null;
|
||||
this.outPointPosition = null;
|
||||
this.currentTime = 0;
|
||||
this.isPlaying = false;
|
||||
|
||||
const groups = ['before', 'annotation', 'after']
|
||||
this.strokeGroups = {};
|
||||
groups.forEach(group => {
|
||||
let groupEl = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
groupEl.classList.add(group)
|
||||
this.svgEl.appendChild(groupEl);
|
||||
this.strokeGroups[group] = new StrokeGroup(groupEl, this);
|
||||
});
|
||||
|
||||
this.annotations = []
|
||||
}
|
||||
|
||||
playlist(url) {
|
||||
const request = new Request(url, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
|
||||
fetch(request)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
let playlist = this.wrapperEl.querySelector('.playlist');
|
||||
if (!playlist) {
|
||||
playlist = document.createElement('nav');
|
||||
playlist.classList.add('playlist');
|
||||
this.wrapperEl.appendChild(playlist)
|
||||
}
|
||||
else {
|
||||
playlist.innerHTML = "";
|
||||
}
|
||||
|
||||
const listEl = document.createElement("ul");
|
||||
for (let fileUrl of data) {
|
||||
const liEl = document.createElement("li");
|
||||
liEl.innerText = fileUrl
|
||||
liEl.addEventListener('click', (e) => {
|
||||
this.play(fileUrl);
|
||||
playlist.style.display = "none";
|
||||
});
|
||||
listEl.appendChild(liEl);
|
||||
}
|
||||
playlist.appendChild(listEl);
|
||||
// do something with the data sent in the request
|
||||
});
|
||||
}
|
||||
|
||||
play(file) {
|
||||
const request = new Request(file, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
fetch(request)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
this.playStrokes(data)
|
||||
// do something with the data sent in the request
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
playStrokes(drawing) {
|
||||
this.strokes = drawing.shape.map(s => new Stroke(s['color'], s['points']));
|
||||
this.currentPathI = null;
|
||||
this.currentPointI = null;
|
||||
this.dimensions = drawing.dimensions;
|
||||
this.svgEl.setAttribute('viewBox', `0 0 ${this.dimensions[0]} ${this.dimensions[1]}`)
|
||||
this.startTime = window.performance.now() - this.strokes[0].points[0][3];
|
||||
this.playStrokePosition(0, 1);
|
||||
this.duration = this.getDuration();
|
||||
this.scrubberElOld.max = this.duration;
|
||||
this.playTimout = null;
|
||||
|
||||
const formatter = wNumb({
|
||||
decimals: 2,
|
||||
edit: (time) => {
|
||||
const s = Math.floor(time/1000);
|
||||
const minutes = Math.floor(s / 60);
|
||||
const seconds = s - minutes * 60;
|
||||
const ms = Math.floor((time/1000 - s) * 1000);
|
||||
return `${minutes}:${seconds}:${ms}`;
|
||||
}
|
||||
});
|
||||
const slider = noUiSlider.create(this.scrubberEl, {
|
||||
start: [this.currentTime, this.duration],
|
||||
connect: true,
|
||||
range: {
|
||||
'min': 0,
|
||||
'max': this.duration
|
||||
},
|
||||
tooltips: [
|
||||
formatter,
|
||||
formatter
|
||||
],
|
||||
// pips: {
|
||||
// mode: 'range',
|
||||
// density: 3,
|
||||
// format: formatter
|
||||
// }
|
||||
});
|
||||
|
||||
slider.on("slide", (values, handle) => {
|
||||
this.isPlaying = false;
|
||||
// console.log(values, handle);
|
||||
// both in and out need to have a value
|
||||
this.inPointPosition = this.findPositionForTime(values[0]);
|
||||
this.outPointPosition = this.findPositionForTime(values[1]);
|
||||
// if (handle === 0) {
|
||||
// // in point
|
||||
// if (
|
||||
// this.currentPathI < this.inPointPosition[0] ||
|
||||
// this.currentPointI < this.inPointPosition[1]) {
|
||||
// this.drawStrokePosition(
|
||||
// // this.inPointPosition[0],
|
||||
// // this.inPointPosition[1],
|
||||
// // always draw at out position, as to see the whole shape of the range
|
||||
// this.outPointPosition[0],
|
||||
// this.outPointPosition[1],
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
// if (handle === 1) {
|
||||
// // out point
|
||||
// // this.outPointPosition = this.findPositionForTime(values[1]);
|
||||
// this.drawStrokePosition(
|
||||
// this.outPointPosition[0],
|
||||
// this.outPointPosition[1],
|
||||
// );
|
||||
// }
|
||||
this.drawStrokePosition(this.inPointPosition, this.outPointPosition);
|
||||
// this.inPointPosition = values;
|
||||
// this.outPointPosition = vaalues[0];
|
||||
// this.scrubTo()
|
||||
// this.scrubTo(ev.target.value);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
getDuration() {
|
||||
const points = this.strokes[this.strokes.length - 1].points;
|
||||
return points[points.length - 1][3];
|
||||
}
|
||||
|
||||
getStrokesSliceForPathRange(in_point, out_point) {
|
||||
// get paths for given range. Also, split path at in & out if necessary.
|
||||
let slices = {};
|
||||
for (let i = in_point[0]; i <= out_point[0]; i++) {
|
||||
const stroke = this.strokes[i];
|
||||
if(typeof stroke === 'undefined'){
|
||||
// out point can be Infinity. So interrupt whenever the end is reached
|
||||
break;
|
||||
}
|
||||
const in_i = (in_point[0] === i) ? in_point[1] : 0;
|
||||
const out_i = (out_point[0] === i) ? out_point[1] : Infinity;
|
||||
|
||||
slices[i] = new StrokeSlice(stroke, in_i, out_i);
|
||||
}
|
||||
return slices;
|
||||
}
|
||||
|
||||
// TODO: when drawing, have a group active & inactive.
|
||||
// active is getPathRange(currentIn, currentOut)
|
||||
// inactive is what comes before and after.
|
||||
// then, playing the video is just running pathRanghe(0, playhead)
|
||||
drawStrokePosition(in_point, out_point, show_all) {
|
||||
if(typeof show_all === 'undefined')
|
||||
show_all = true;
|
||||
|
||||
this.strokeGroups['before'].setStrokes(this.getStrokesSliceForPathRange([0,0], in_point));
|
||||
this.strokeGroups['annotation'].setStrokes(this.getStrokesSliceForPathRange(in_point, out_point));
|
||||
this.strokeGroups['after'].setStrokes(this.getStrokesSliceForPathRange(out_point, [Infinity, Infinity]));
|
||||
|
||||
|
||||
// // an inpoint is set, so we're annotating
|
||||
// // make everything coming before translucent
|
||||
// if (this.inPointPosition !== null) {
|
||||
// const [inPath_i, inPoint_i] = this.inPointPosition;
|
||||
// // returns a static NodeList
|
||||
// const currentBeforeEls = this.svgEl.querySelectorAll(`.before_in`);
|
||||
// for (let currentBeforeEl of currentBeforeEls) {
|
||||
// currentBeforeEl.classList.remove('before_in');
|
||||
// }
|
||||
|
||||
// for (let index = 0; index < inPath_i; index++) {
|
||||
// const pathEl = this.svgEl.querySelector(`.path${index}`);
|
||||
// if (pathEl) {
|
||||
// pathEl.classList.add('before_in');
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// this.currentPathI = path_i;
|
||||
// this.currentPointI = point_i;
|
||||
|
||||
// const path = this.strokes[path_i];
|
||||
// // console.log(path);
|
||||
// let pathEl = this.svgEl.querySelector(`.path${path_i}`);
|
||||
// if (!pathEl) {
|
||||
// pathEl = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
// pathEl.style.stroke = path.color;
|
||||
// pathEl.classList.add('path' + path_i)
|
||||
// this.svgEl.appendChild(pathEl)
|
||||
// }
|
||||
|
||||
// const stroke = path.points.slice(0, point_i);
|
||||
// const d = this.strokes2D(stroke);
|
||||
// pathEl.setAttribute('d', d);
|
||||
|
||||
// this.scrubberElOld.value = path.points[point_i][3];
|
||||
// this.currentTime = path.points[point_i][3];
|
||||
}
|
||||
|
||||
getNextPosition(path_i, point_i) {
|
||||
const path = this.strokes[path_i];
|
||||
let next_path, next_point;
|
||||
if (path.points.length > point_i + 1) {
|
||||
next_path = path_i;
|
||||
next_point = point_i + 1;
|
||||
// setTimeout(() => this.playStroke(next_path, next_point), dt);
|
||||
} else if (this.strokes.length > path_i + 1) {
|
||||
next_path = path_i + 1;
|
||||
next_point = 1;
|
||||
// use starttime instead of diff, to prevent floating
|
||||
} else {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
// when an outpoint is set, stop playing there
|
||||
if(this.outPointPosition && (next_path > this.outPointPosition[0] || next_point > this.outPointPosition[1])){
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
return [next_path, next_point];
|
||||
}
|
||||
|
||||
playStrokePosition(path_i, point_i, allow_interrupt) {
|
||||
if(allow_interrupt) {
|
||||
if(!this.isPlaying) {
|
||||
console.log('not playing because of interrupt');
|
||||
return;
|
||||
}
|
||||
} else{
|
||||
this.isPlaying = true;
|
||||
}
|
||||
this.drawStrokePosition(path_i, point_i);
|
||||
|
||||
const [next_path, next_point] = this.getNextPosition(path_i, point_i);
|
||||
if (next_path === null) {
|
||||
console.log('done playing');
|
||||
return;
|
||||
}
|
||||
|
||||
const t = this.strokes[next_path].points[next_point][3];// - path.points[point_i][3];
|
||||
|
||||
const dt = t - (window.performance.now() - this.startTime);
|
||||
this.playTimout = setTimeout(() => this.playStrokePosition(next_path, next_point, true), dt);
|
||||
}
|
||||
|
||||
playUntil(path_i) {
|
||||
// for scrubber
|
||||
}
|
||||
|
||||
scrubTo(ms) {
|
||||
const [path_i, point_i] = this.findPositionForTime(ms);
|
||||
// console.log(path_i, point_i);
|
||||
clearTimeout(this.playTimout);
|
||||
this.playStrokePosition(path_i, point_i);
|
||||
// this.playHead = ms;
|
||||
}
|
||||
|
||||
|
||||
|
||||
findPositionForTime(ms) {
|
||||
ms = Math.min(Math.max(ms, 0), this.duration);
|
||||
console.log('scrub to', ms)
|
||||
let path_i = 0;
|
||||
let point_i = 0;
|
||||
this.strokes.every((stroke, index) => {
|
||||
const startAt = stroke.points[0][3];
|
||||
const endAt = stroke.points[stroke.points.length - 1][3];
|
||||
|
||||
if (startAt > ms) {
|
||||
return false; // too far
|
||||
}
|
||||
if (endAt > ms) {
|
||||
// we're getting close. Find the right point_i
|
||||
path_i = index;
|
||||
stroke.points.every((point, pi) => {
|
||||
if (point[3] > ms) {
|
||||
// too far
|
||||
return false;
|
||||
}
|
||||
point_i = pi;
|
||||
return true;
|
||||
});
|
||||
return false;
|
||||
} else {
|
||||
// in case nothings comes after, we store the last best option thus far
|
||||
path_i = index;
|
||||
point_i = stroke.points.length - 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
});
|
||||
return [path_i, point_i];
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -3,6 +3,8 @@
|
|||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<!-- <meta name="viewport" content="width=device-width, initial-scale=1.0"> -->
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0' />
|
||||
<title>Draw a line animation</title>
|
||||
<style media="screen">
|
||||
#sample,
|
||||
|
|
|
@ -31,9 +31,9 @@ class Canvas {
|
|||
|
||||
this.startTime = null;
|
||||
|
||||
document.body.addEventListener('mousemove', this.draw.bind(this));
|
||||
document.body.addEventListener('mouseup', this.penup.bind(this));
|
||||
this.svgEl.addEventListener('mousedown', this.startStroke.bind(this));
|
||||
document.body.addEventListener('pointermove', this.draw.bind(this));
|
||||
document.body.addEventListener('pointerup', this.penup.bind(this));
|
||||
this.svgEl.addEventListener('pointerdown', this.startStroke.bind(this));
|
||||
|
||||
this.createToolbox();
|
||||
|
||||
|
|
Loading…
Reference in a new issue