better syntax highlighter demo, some bugfixes
This commit is contained in:
parent
37219bfbe9
commit
0b629a1b40
4 changed files with 214 additions and 189 deletions
|
@ -3,48 +3,61 @@ Ox.load('UI', {
|
|||
theme: 'classic'
|
||||
}, function() {
|
||||
|
||||
Ox.Theme('classic');
|
||||
var $button = Ox.Button({
|
||||
title: 'Run',
|
||||
width: 256
|
||||
})
|
||||
.css({
|
||||
marginTop: '256px'
|
||||
})
|
||||
.click(function() {
|
||||
$syntaxHighlighter.options({
|
||||
source: $textarea.value()
|
||||
});
|
||||
}),
|
||||
$options = Ox.Element()
|
||||
.append(
|
||||
Ox.FormElementGroup({
|
||||
elements: ['showLineNumbers', 'showLinebreaks', 'showTabs', 'showWhitespace'].map(function(v, i) {
|
||||
return Ox.Checkbox({
|
||||
overlap: 'right',
|
||||
title: Ox.toDashes(v).split('-').map(function(v) { return Ox.toTitleCase(v); }).join(' '),
|
||||
width: 160
|
||||
}).bindEvent({
|
||||
change: function(event) {
|
||||
$syntaxHighlighter.options(v, event.checked);
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
),
|
||||
$syntaxHighlighter = Ox.SyntaxHighlighter(),
|
||||
$textarea = Ox.Input({
|
||||
height: 256,
|
||||
type: 'textarea',
|
||||
width: 256
|
||||
})
|
||||
.css({
|
||||
fontFamily: 'Menlo, Monaco, Courier, Courier New'
|
||||
});
|
||||
|
||||
var $body = $('body'),
|
||||
$textarea = new Ox.Input({
|
||||
height: 400,
|
||||
type: 'textarea',
|
||||
width: 400
|
||||
})
|
||||
.css({
|
||||
fontFamily: 'Menlo, Monaco, Courier, Courier New'
|
||||
})
|
||||
.appendTo($body),
|
||||
$button = new Ox.Button({
|
||||
title: 'Run',
|
||||
width: 40
|
||||
})
|
||||
.css({
|
||||
position: 'absolute',
|
||||
left: '8px',
|
||||
top: '416px',
|
||||
})
|
||||
.bindEvent({
|
||||
click: function() {
|
||||
$div.empty();
|
||||
new Ox.SyntaxHighlighter({
|
||||
showLinebreaks: true,
|
||||
showTabs: true,
|
||||
showWhitespace: true,
|
||||
source: $textarea.value(),
|
||||
//stripComments: true,
|
||||
//stripLinebreaks: true,
|
||||
//stripWhitespace: true,
|
||||
}).appendTo($div);
|
||||
}
|
||||
})
|
||||
.appendTo($body),
|
||||
$div = $('<div>')
|
||||
.css({
|
||||
position: 'absolute',
|
||||
left: '416px',
|
||||
top: '8px'
|
||||
})
|
||||
.appendTo($body);
|
||||
Ox.SplitPanel({
|
||||
elements: [
|
||||
{
|
||||
element: Ox.Element()
|
||||
.append($textarea)
|
||||
.append($button),
|
||||
resizable: true,
|
||||
resize: [128, 256, 384],
|
||||
size: 256
|
||||
},
|
||||
{
|
||||
element: Ox.Container()
|
||||
.append($options)
|
||||
.append($syntaxHighlighter)
|
||||
}
|
||||
],
|
||||
orientation: 'horizontal'
|
||||
}).appendTo(Ox.UI.$body)
|
||||
|
||||
});
|
|
@ -129,6 +129,7 @@ Ox.DocPage = function(options, self) {
|
|||
);
|
||||
$elements.push(
|
||||
Ox.SyntaxHighlighter({
|
||||
showLineNumbers: true,
|
||||
// fixme: silly
|
||||
source: item.source.map(function(token) {
|
||||
return token.source;
|
||||
|
|
|
@ -7,11 +7,12 @@
|
|||
Ox.SyntaxHighlighter = function(options, self) {
|
||||
|
||||
self = self || {};
|
||||
var that = new Ox.Element({}, self)
|
||||
var that = Ox.Element({}, self)
|
||||
.defaults({
|
||||
height: 40,
|
||||
lineLength: 80, //@ number of characters per line
|
||||
offset: 1, //@ first line number
|
||||
showLineNumbers: false,
|
||||
showLinebreaks: false, //@ show linebreak symbols
|
||||
showTabs: false, //@ show tab symbols
|
||||
showWhitespace: false, //@ show irregular leading or trailing whitespace
|
||||
|
@ -25,71 +26,7 @@ Ox.SyntaxHighlighter = function(options, self) {
|
|||
.options(options || {})
|
||||
.addClass('OxSyntaxHighlighter');
|
||||
|
||||
self.options.source = self.options.source
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\r/g, '\n');
|
||||
|
||||
self.cursor = 0;
|
||||
self.source = '';
|
||||
self.tokens = Ox.tokenize(self.options.source);
|
||||
self.tokens.forEach(function(token, i) {
|
||||
var classNames,
|
||||
source = self.options.source.substr(token.offset, token.length);
|
||||
if (
|
||||
!(self.options.stripComments && token.type == 'comment')
|
||||
) {
|
||||
classNames = 'Ox' + Ox.toTitleCase(token.type);
|
||||
if (self.options.showWhitespace && token.type == 'whitespace') {
|
||||
if (isAfterLinebreak() && hasIrregularSpaces()) {
|
||||
classNames += ' OxLeading'
|
||||
} else if (isBeforeLinebreak()) {
|
||||
classNames += ' OxTrailing'
|
||||
}
|
||||
}
|
||||
self.source += '<span class="' + classNames + '">' +
|
||||
encodeToken(source, token.type) + '</span>';
|
||||
}
|
||||
self.cursor += token.length;
|
||||
function isAfterLinebreak() {
|
||||
return i == 0 ||
|
||||
self.tokens[i - 1].type == 'linebreak';
|
||||
}
|
||||
function isBeforeLinebreak() {
|
||||
return i == self.tokens.length - 1 ||
|
||||
self.tokens[i + 1].type == 'linebreak';
|
||||
}
|
||||
function hasIrregularSpaces() {
|
||||
return source.split('').reduce(function(prev, curr) {
|
||||
return prev + (curr == ' ' ? 1 : 0);
|
||||
}, 0) % self.options.tabLength;
|
||||
}
|
||||
});
|
||||
self.lines = self.source.split('<br/>');
|
||||
self.lineNumbersWidth = (
|
||||
self.lines.length + self.options.offset - 1
|
||||
).toString().length * 7;
|
||||
|
||||
self.$lineNumbers = new Ox.Element()
|
||||
.addClass('OxLineNumbers')
|
||||
.css({
|
||||
display: 'table-cell',
|
||||
width: self.lineNumbersWidth + 'px',
|
||||
padding: '4px',
|
||||
})
|
||||
.html(
|
||||
Ox.range(self.lines.length).map(function(line) {
|
||||
return (line + self.options.offset);
|
||||
}).join('<br/>')
|
||||
)
|
||||
.appendTo(that);
|
||||
self.$source = new Ox.Element()
|
||||
.addClass('OxSourceCode')
|
||||
.css({
|
||||
display: 'table-cell',
|
||||
padding: '4px'
|
||||
})
|
||||
.html(self.source)
|
||||
.appendTo(that);
|
||||
renderSource();
|
||||
|
||||
function encodeToken(source, token) {
|
||||
var linebreak = '<br/>',
|
||||
|
@ -110,8 +47,74 @@ Ox.SyntaxHighlighter = function(options, self) {
|
|||
.replace(/\n/g, linebreak);
|
||||
}
|
||||
|
||||
self.setOption = function() {
|
||||
function renderSource() {
|
||||
self.options.source = self.options.source
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\r/g, '\n');
|
||||
self.cursor = 0;
|
||||
self.source = '';
|
||||
self.tokens = Ox.tokenize(self.options.source);
|
||||
self.tokens.forEach(function(token, i) {
|
||||
var classNames,
|
||||
source = self.options.source.substr(token.offset, token.length);
|
||||
if (
|
||||
!(self.options.stripComments && token.type == 'comment')
|
||||
) {
|
||||
classNames = 'Ox' + Ox.toTitleCase(token.type);
|
||||
if (self.options.showWhitespace && token.type == 'whitespace') {
|
||||
if (isAfterLinebreak() && hasIrregularSpaces()) {
|
||||
classNames += ' OxLeading'
|
||||
} else if (isBeforeLinebreak()) {
|
||||
classNames += ' OxTrailing'
|
||||
}
|
||||
}
|
||||
self.source += '<span class="' + classNames + '">' +
|
||||
encodeToken(source, token.type) + '</span>';
|
||||
}
|
||||
self.cursor += token.length;
|
||||
function isAfterLinebreak() {
|
||||
return i == 0 ||
|
||||
self.tokens[i - 1].type == 'linebreak';
|
||||
}
|
||||
function isBeforeLinebreak() {
|
||||
return i == self.tokens.length - 1 ||
|
||||
self.tokens[i + 1].type == 'linebreak';
|
||||
}
|
||||
function hasIrregularSpaces() {
|
||||
return source.split('').reduce(function(prev, curr) {
|
||||
return prev + (curr == ' ' ? 1 : 0);
|
||||
}, 0) % self.options.tabLength;
|
||||
}
|
||||
});
|
||||
self.lines = self.source.split('<br/>');
|
||||
|
||||
that.empty();
|
||||
if (self.options.showLineNumbers) {
|
||||
self.$lineNumbers = new Ox.Element()
|
||||
.addClass('OxLineNumbers')
|
||||
.css({
|
||||
display: 'table-cell',
|
||||
padding: '4px',
|
||||
})
|
||||
.html(
|
||||
Ox.range(self.lines.length).map(function(line) {
|
||||
return (line + self.options.offset);
|
||||
}).join('<br/>')
|
||||
)
|
||||
.appendTo(that);
|
||||
}
|
||||
self.$source = new Ox.Element()
|
||||
.addClass('OxSourceCode')
|
||||
.css({
|
||||
display: 'table-cell',
|
||||
padding: '4px'
|
||||
})
|
||||
.html(self.source)
|
||||
.appendTo(that);
|
||||
}
|
||||
|
||||
self.setOption = function(key, value) {
|
||||
renderSource();
|
||||
};
|
||||
|
||||
return that;
|
||||
|
|
166
source/Ox.js
166
source/Ox.js
|
@ -73,7 +73,7 @@ Ox.print <f> Prints its arguments to the console
|
|||
The string contains the timestamp, the name of the caller function, and
|
||||
any arguments, separated by spaces
|
||||
arg <*> any value
|
||||
> Ox.print("foo").substr(-3)
|
||||
> Ox.print('foo').split(' ').pop()
|
||||
"foo"
|
||||
@*/
|
||||
|
||||
|
@ -289,7 +289,26 @@ Ox.clone = function(obj) {
|
|||
};
|
||||
|
||||
/*@
|
||||
Ox.count <f> Counts the occurences of values in an array, object or string
|
||||
Ox.contains <f> Tests if a collection contains a value
|
||||
> Ox.contains(['foo', 'bar'], 'foo')
|
||||
true
|
||||
> Ox.contains({foo: 'bar'}, 'bar')
|
||||
true
|
||||
> Ox.contains({foo: 'bar'}, 'foo')
|
||||
false
|
||||
> Ox.contains("foobar", "bar")
|
||||
true
|
||||
@*/
|
||||
Ox.contains = function(col, val) {
|
||||
/*
|
||||
// fixme: rename to Ox.has or Ox.isIn?
|
||||
// then it'd become convenient for arrays
|
||||
*/
|
||||
return (Ox.isObject(col) ? Ox.values(col) : col).indexOf(val) > -1;
|
||||
};
|
||||
|
||||
/*@
|
||||
Ox.count <f> Counts the occurences of values in a collection
|
||||
> Ox.count(['f', 'o', 'o'])
|
||||
{f: 1, o: 2}
|
||||
> Ox.count({a: 'f', b: 'o', c: 'o'})
|
||||
|
@ -330,7 +349,7 @@ Ox.each = function(obj, fn) {
|
|||
};
|
||||
|
||||
/*@
|
||||
Ox.every <f> Returns true if a condition holds for every element of a collection
|
||||
Ox.every <f> Tests if every element of a collection satisfies a given condition
|
||||
Unlike <code>[].every()</code>, <code>Ox.every()</code> works for arrays,
|
||||
objects and strings.
|
||||
> Ox.every([0, 1, 2], function(v, i) { return i == v; })
|
||||
|
@ -436,13 +455,11 @@ Ox.forEach = function(obj, fn) {
|
|||
|
||||
/*@
|
||||
Ox.getObjectById <f> Returns an array element with a given id
|
||||
> Ox.getObjectById([{id: "foo", title: "Foo"}, {id: "bar", title: "Bar"}], "foo")
|
||||
{id: "foo", title: "Foo"}
|
||||
@*/
|
||||
// fixme: shouldn't this be getElementById() ?
|
||||
Ox.getObjectById = function(arr, id) {
|
||||
/***
|
||||
>>> Ox.getObjectById([{id: "foo", title: "Foo"}, {id: "bar", title: "Bar"}], "foo").title
|
||||
"Foo"
|
||||
***/
|
||||
var ret = null;
|
||||
Ox.forEach(arr, function(v) {
|
||||
if (v.id == id) {
|
||||
|
@ -455,13 +472,11 @@ Ox.getObjectById = function(arr, id) {
|
|||
|
||||
/*@
|
||||
Ox.getPositionById <f> Returns the index of an array element with a given id
|
||||
> Ox.getPositionById([{id: "foo", title: "Foo"}, {id: "bar", title: "Bar"}], "foo")
|
||||
0
|
||||
@*/
|
||||
// fixme: shouldn't this be getIndexById() ?
|
||||
Ox.getPositionById = function(arr, id) {
|
||||
/***
|
||||
>>> Ox.getPositionById([{id: "foo", title: "Foo"}, {id: "bar", title: "Bar"}], "bar")
|
||||
1
|
||||
***/
|
||||
var ret = -1;
|
||||
Ox.forEach(arr, function(v, i) {
|
||||
if (v.id == id) {
|
||||
|
@ -543,6 +558,8 @@ Ox.isEmpty <f> Returns true if a collection is empty
|
|||
true
|
||||
> Ox.isEmpty('')
|
||||
true
|
||||
> Ox.isEmpty(function() {})
|
||||
true
|
||||
@*/
|
||||
Ox.isEmpty = function(val) {
|
||||
return Ox.len(val) == 0;
|
||||
|
@ -620,7 +637,7 @@ Ox.len = function(obj) {
|
|||
|
||||
/*@
|
||||
Ox.loop <f> For-loop, functional-style
|
||||
Returning <code>false</code> from the iterater function acts like a
|
||||
Returning <code>false</code> from the iterator function acts like a
|
||||
<code>break</code> statement. Unlike a <code>for</code> loop,
|
||||
<code>Ox.loop</code> doesn't leak its counter variable to the outer scope,
|
||||
but returns it.
|
||||
|
@ -630,8 +647,10 @@ Ox.loop <f> For-loop, functional-style
|
|||
equivalent to <code>for (var i = start; i < stop; i++)</code> or,
|
||||
if <code>start</code> is larger than <code>stop</code>,
|
||||
<code>for (var i = start; i > stop; i--)</code>
|
||||
(start, stop, step) -> <n> Next value
|
||||
equivalent to <code>for (var i = start; i < stop; i += step)</code>
|
||||
(start, stop, step, callback) -> <n> Next value
|
||||
equivalent to <code>for (var i = start; i < stop; i += step)</code> or,
|
||||
if <code>step</code> is negative,
|
||||
<code>for (var i = start; i > stop; i += step)</code>
|
||||
start <n> Start value
|
||||
stop <n> Stop value (exclusive)
|
||||
step <n> Step value
|
||||
|
@ -919,8 +938,7 @@ Ox.values <f> Returns the values of a collection
|
|||
[1, 3]
|
||||
@*/
|
||||
Ox.values = function(col) {
|
||||
// this happens to works for arrays and strings, but still:
|
||||
// Ox.values(arr) -> arr, Ox.values(str) -> str.split('')
|
||||
// Ox.values(str) is identical to str.split('')
|
||||
var values = [];
|
||||
Ox.forEach(col, function(val) {
|
||||
values.push(val);
|
||||
|
@ -3614,7 +3632,9 @@ Ox.sinh = function(x) {
|
|||
|
||||
//@ Constants ------------------------------------------------------------------
|
||||
|
||||
//@ Ox.MAX_LATITUDE <n> Maximum latitude of a Mercator projection
|
||||
Ox.MAX_LATITUDE = Ox.deg(Math.atan(Ox.sinh(Math.PI)));
|
||||
//@ Ox.MIN_LATITUDE <n> Minimum latitude of a Mercator projection
|
||||
Ox.MIN_LATITUDE = -Ox.MAX_LATITUDE;
|
||||
|
||||
//@ Object ---------------------------------------------------------------------
|
||||
|
@ -3811,44 +3831,32 @@ Ox.char <f> Alias for String.fromCharCode
|
|||
// fixme: add some mapping? like Ox.char(9, 13) or Ox.char([9, 13])?
|
||||
Ox.char = String.fromCharCode;
|
||||
|
||||
Ox.clean = function(str) {
|
||||
/*
|
||||
>>> Ox.clean("foo bar")
|
||||
/*@
|
||||
Ox.clean <f> Remove leading, trailing and double whitespace from a string
|
||||
> Ox.clean("foo bar")
|
||||
"foo bar"
|
||||
>>> Ox.clean(" foo bar ")
|
||||
> Ox.clean(" foo bar ")
|
||||
"foo bar"
|
||||
>>> Ox.clean(" foo \n bar ")
|
||||
> Ox.clean(" foo \n bar ")
|
||||
"foo\nbar"
|
||||
*/
|
||||
@*/
|
||||
Ox.clean = function(str) {
|
||||
return Ox.map(str.split('\n'), function(str) {
|
||||
return Ox.trim(str.replace(/\s+/g, ' '));
|
||||
}).join('\n');
|
||||
};
|
||||
|
||||
Ox.contains = function(str, chr) {
|
||||
/*
|
||||
>>> Ox.contains("foo", "bar")
|
||||
false
|
||||
>>> Ox.contains("foobar", "bar")
|
||||
true
|
||||
>>> Ox.contains(['foo', 'bar'], 'foo')
|
||||
true
|
||||
// fixme: rename to Ox.has or Ox.isIn?
|
||||
// then it'd become convenient for arrays
|
||||
*/
|
||||
return str.indexOf(chr) > -1;
|
||||
};
|
||||
|
||||
/*@
|
||||
Ox.endsWith <f> Checks if a string ends with a given substring
|
||||
While <code>Ox.endsWith('foobar', 'bar')</code> is longer than
|
||||
<code>/bar$/.test('foobar')</code>, <code>Ox.endsWith('foobar', bar)</code>
|
||||
is shorter than <code>new RegExp(bar + '$').test('foobar')</code>.
|
||||
If the substring is a string literal (and not a variable),
|
||||
<code>/sub$/.test(str)</code> or <code>!!/sub$/(str)</code>
|
||||
is shorter than <code>Ox.ends(str, sub)</code>.
|
||||
> Ox.endsWith('foobar', 'bar')
|
||||
true
|
||||
@*/
|
||||
Ox.endsWith = function(str, sub) {
|
||||
return new RegExp(sub + '$').test(str);
|
||||
// fixme: rename to ends
|
||||
return str.substr(str.length - sub.length) == sub;
|
||||
};
|
||||
|
||||
Ox.highlight = function(txt, str) {
|
||||
|
@ -3928,23 +3936,27 @@ Ox.parsePath = function(str) {
|
|||
};
|
||||
}
|
||||
|
||||
Ox.repeat = function(obj, num) {
|
||||
/*
|
||||
works for arrays, numbers and strings
|
||||
>>> Ox.repeat(1, 3)
|
||||
/*@
|
||||
Ox.repeat <f> Repeat a value multiple times
|
||||
Works for arrays, numbers and strings
|
||||
> Ox.repeat(1, 3)
|
||||
"111"
|
||||
>>> Ox.repeat("foo", 3)
|
||||
> Ox.repeat("foo", 3)
|
||||
"foofoofoo"
|
||||
>>> Ox.repeat([1, 2], 3)
|
||||
> Ox.repeat([1, 2], 3)
|
||||
[1, 2, 1, 2, 1, 2]
|
||||
*/
|
||||
@*/
|
||||
Ox.repeat = function(val, num) {
|
||||
var ret;
|
||||
if (Ox.isArray(obj)) {
|
||||
ret = num >= 1 ? Ox.map(Ox.range(obj.length * num), function(v, i) {
|
||||
return obj[i % obj.length]
|
||||
}) : [];
|
||||
if (Ox.isArray(val)) {
|
||||
ret = [];
|
||||
if (num >= 1) {
|
||||
Ox.loop(num, function() {
|
||||
ret = Ox.merge(ret, val);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
ret = num >= 1 ? new Array(num + 1).join(obj.toString()) : '';
|
||||
ret = num >= 1 ? new Array(num + 1).join(val.toString()) : '';
|
||||
}
|
||||
return ret;
|
||||
};
|
||||
|
@ -3959,22 +3971,15 @@ Ox.reverse = function(str) {
|
|||
|
||||
/*@
|
||||
Ox.startsWith <f> Checks if a string starts with a given substring
|
||||
While <code>Ox.startsWith('foobar', 'foo')</code> is longer than
|
||||
<code>/^foo/.test('foobar')</code>, <code>Ox.startsWith('foobar', foo)</code>
|
||||
is shorter than <code>new RegExp('^' + foo).test('foobar')</code>.
|
||||
> Ox.endsWith('foobar', 'bar')
|
||||
If the substring is a string literal (and not a variable),
|
||||
<code>/^sub/.test(str)</code> or <code>!!/^sub/(str)</code>
|
||||
is shorter than <code>Ox.starts(str, sub)</code>.
|
||||
> Ox.startsWith('foobar', 'foo')
|
||||
true
|
||||
@*/
|
||||
Ox.startsWith = function(str, sub) {
|
||||
/*
|
||||
>>> Ox.startsWith("foobar", "foo")
|
||||
true
|
||||
// fixme:
|
||||
// !!(/^sub/(str)) is shorter than
|
||||
// Ox.startsWith(str, sub) anyway
|
||||
// new RegExp('^' + sub).test(str) is longer though...
|
||||
*/
|
||||
return new RegExp('^' + sub).test(str);
|
||||
// fixme: rename to starts
|
||||
return str.substr(0, sub.length) == sub;
|
||||
};
|
||||
|
||||
/*@
|
||||
|
@ -3983,26 +3988,29 @@ Ox.stripTags <f> Strips HTML tags from a string
|
|||
'foo'
|
||||
@*/
|
||||
Ox.stripTags = function(str) {
|
||||
return str.replace(/(<.*?>)/g, '');
|
||||
return str.replace(/<.*?>/g, '');
|
||||
};
|
||||
|
||||
/*@
|
||||
Ox.substr <f> A better <code>substr</code>
|
||||
> Ox.substr('foobar', 1)
|
||||
"oobar"
|
||||
> Ox.substr('foobar', -1)
|
||||
"r"
|
||||
> Ox.substr('foobar', 1, 5)
|
||||
"ooba"
|
||||
> Ox.substr('foobar', 1, -1)
|
||||
"ooba"
|
||||
> Ox.substr('foobar', -5, 5)
|
||||
"ooba"
|
||||
> Ox.substr('foobar', -5, -1)
|
||||
"ooba"
|
||||
@*/
|
||||
Ox.substr = function(str, start, stop) {
|
||||
/***
|
||||
// fixme: needed?
|
||||
Ox.substr behaves like str[start:stop] in Python
|
||||
(or like str.substring() with negative values for stop)
|
||||
// fixme: needed?
|
||||
>>> Ox.substr('foobar', 1)
|
||||
"oobar"
|
||||
>>> Ox.substr('foobar', -1)
|
||||
"r"
|
||||
>>> Ox.substr('foobar', 1, 3)
|
||||
"oo"
|
||||
>>> Ox.substr('foobar', -3, 5)
|
||||
"ba"
|
||||
>>> Ox.substr('foobar', 1, -2)
|
||||
"oob"
|
||||
>>> Ox.substr('foobar', -4, -1)
|
||||
"oba"
|
||||
***/
|
||||
stop = Ox.isUndefined(stop) ? str.length : stop;
|
||||
return str.substring(
|
||||
|
|
Loading…
Reference in a new issue