Merge pull request #3232 from ellisonbg/codemirror3

Update to CodeMirror 3 and start to ship our components

The main purpose of this branch is the update to CodeMirror 3 and ship it as a bower component. Because CodeMirror doesn't use proper semantic version tags, we have created a CodeMirror ipython fork. The sole purpose of this fork is for us to maintain properly named tags that bower can install from.

As a side effect of these changes, we are now shipping in place all bower components that we are using (bootstrap, less. codemirror, jquery). BUT, the version of jquery in components is not yet being used as we are still stuck with the old version attached to the nasty jquery.ui branch we ship. In a later PR, we will need to get rid of that jquery.ui branch (replace those capabilties with bootstrap) and then move to a stable version of jquery.ui that matches that of the jquery component depended upon by bootstrap.
pull/37/head
Min RK 13 years ago
commit f04e1d8707

1
.gitignore vendored

@ -6,7 +6,6 @@ docs/man/*.gz
docs/source/api/generated
docs/gh-pages
IPython/frontend/html/notebook/static/mathjax
IPython/frontend/html/notebook/static/components
*.py[co]
__pycache__
build

@ -0,0 +1,29 @@
# IPython Notebook development
# Development dependencies
Developers of the IPython Notebook will need to install the following tools:
* fabric
* node.js
* less (`npm install -g less`)
* bower (`npm install -g bower`)
# Components
We are moving to a model where our JavaScript dependencies are managed using
[bower](http://bower.io/). These packages are installed in `static/components`
and commited into our git repo. Our dependencies are described in the file
`static/bower.json`. To update our bower packages, run `fab components` in this
directory.
Because CodeMirror does not use proper semantic versioning for its GitHub tags,
we maintain our own fork of CodeMirror that is used with bower. This fork should
track the upstream CodeMirror exactly; the only difference is that we are adding
semantic versioned tags to our repo.
# less
If you edit our `.less` files you will need to run the less compiler to build
our minified css files. This can be done by running `fab css` from this directory.

@ -7,9 +7,6 @@ import os
static_dir = 'static'
components_dir = os.path.join(static_dir,'components')
def test_component(name):
if not os.path.exists(os.path.join(components_dir,name)):
components()
def components():
"""install components with bower"""
@ -18,8 +15,6 @@ def components():
def css(minify=True):
"""generate the css from less files"""
test_component('bootstrap')
test_component('less.js')
if minify not in ['True', 'False', True, False]:
abort('minify must be Boolean')
minify = (minify in ['True',True])

@ -1,19 +1,16 @@
CodeMirror.defineMode("python", function(conf, parserConf) {
var ERRORCLASS = 'error';
function wordRegexp(words) {
return new RegExp("^((" + words.join(")|(") + "))\\b");
}
// IPython-specific changes: add '?' as recognized character.
var singleOperators = new RegExp("^[\\+\\-\\*/%&|\\^~<>!\\?]");
// End IPython changes.
var singleDelimiters = new RegExp('^[\\(\\)\\[\\]\\{\\}@,:`=;\\.]');
var doubleOperators = new RegExp("^((==)|(!=)|(<=)|(>=)|(<>)|(<<)|(>>)|(//)|(\\*\\*))");
var doubleDelimiters = new RegExp("^((\\+=)|(\\-=)|(\\*=)|(%=)|(/=)|(&=)|(\\|=)|(\\^=))");
var tripleDelimiters = new RegExp("^((//=)|(>>=)|(<<=)|(\\*\\*=))");
var identifiers = new RegExp("^[_A-Za-z][_A-Za-z0-9]*");
var singleOperators = parserConf.singleOperators || new RegExp("^[\\+\\-\\*/%&|\\^~<>!]");
var singleDelimiters = parserConf.singleDelimiters || new RegExp('^[\\(\\)\\[\\]\\{\\}@,:`=;\\.]');
var doubleOperators = parserConf.doubleOperators || new RegExp("^((==)|(!=)|(<=)|(>=)|(<>)|(<<)|(>>)|(//)|(\\*\\*))");
var doubleDelimiters = parserConf.doubleDelimiters || new RegExp("^((\\+=)|(\\-=)|(\\*=)|(%=)|(/=)|(&=)|(\\|=)|(\\^=))");
var tripleDelimiters = parserConf.tripleDelimiters || new RegExp("^((//=)|(>>=)|(<<=)|(\\*\\*=))");
var identifiers = parserConf.identifiers|| new RegExp("^[_A-Za-z][_A-Za-z0-9]*");
var wordOperators = wordRegexp(['and', 'or', 'not', 'is', 'in']);
var commonkeywords = ['as', 'assert', 'break', 'class', 'continue',
@ -75,15 +72,15 @@ CodeMirror.defineMode("python", function(conf, parserConf) {
if (stream.eatSpace()) {
return null;
}
var ch = stream.peek();
// Handle Comments
if (ch === '#') {
stream.skipToEnd();
return 'comment';
}
// Handle Number Literals
if (stream.match(/^[0-9\.]/, false)) {
var floatLiteral = false;
@ -119,13 +116,13 @@ CodeMirror.defineMode("python", function(conf, parserConf) {
return 'number';
}
}
// Handle Strings
if (stream.match(stringPrefixes)) {
state.tokenize = tokenStringFactory(stream.current());
return state.tokenize(stream, state);
}
// Handle operators and Delimiters
if (stream.match(tripleDelimiters) || stream.match(doubleDelimiters)) {
return null;
@ -138,32 +135,32 @@ CodeMirror.defineMode("python", function(conf, parserConf) {
if (stream.match(singleDelimiters)) {
return null;
}
if (stream.match(keywords)) {
return 'keyword';
}
if (stream.match(builtins)) {
return 'builtin';
}
if (stream.match(identifiers)) {
return 'variable';
}
// Handle non-detected items
stream.next();
return ERRORCLASS;
}
function tokenStringFactory(delimiter) {
while ('rub'.indexOf(delimiter.charAt(0).toLowerCase()) >= 0) {
delimiter = delimiter.substr(1);
}
var singleline = delimiter.length == 1;
var OUTCLASS = 'string';
return function tokenString(stream, state) {
function tokenString(stream, state) {
while (!stream.eol()) {
stream.eatWhile(/[^'"\\]/);
if (stream.eat('\\')) {
@ -186,9 +183,11 @@ CodeMirror.defineMode("python", function(conf, parserConf) {
}
}
return OUTCLASS;
};
}
tokenString.isString = true;
return tokenString;
}
function indent(stream, state, type) {
type = type || 'py';
var indentUnit = 0;
@ -211,7 +210,7 @@ CodeMirror.defineMode("python", function(conf, parserConf) {
type: type
});
}
function dedent(stream, state, type) {
type = type || 'py';
if (state.scopes.length == 1) return;
@ -230,7 +229,7 @@ CodeMirror.defineMode("python", function(conf, parserConf) {
while (state.scopes[0].offset !== _indent) {
state.scopes.shift();
}
return false
return false;
} else {
if (type === 'py') {
state.scopes[0].offset = stream.indentation();
@ -260,7 +259,7 @@ CodeMirror.defineMode("python", function(conf, parserConf) {
}
return style;
}
// Handle decorators
if (current === '@') {
return stream.match(identifiers, false) ? 'meta' : ERRORCLASS;
@ -270,7 +269,7 @@ CodeMirror.defineMode("python", function(conf, parserConf) {
&& state.lastToken === 'meta') {
style = 'meta';
}
// Handle scope changes.
if (current === 'pass' || current === 'return') {
state.dedent += 1;
@ -299,7 +298,7 @@ CodeMirror.defineMode("python", function(conf, parserConf) {
if (state.scopes.length > 1) state.scopes.shift();
state.dedent -= 1;
}
return style;
}
@ -313,27 +312,27 @@ CodeMirror.defineMode("python", function(conf, parserConf) {
dedent: 0
};
},
token: function(stream, state) {
var style = tokenLexer(stream, state);
state.lastToken = style;
if (stream.eol() && stream.lambda) {
state.lambda = false;
}
return style;
},
indent: function(state, textAfter) {
indent: function(state) {
if (state.tokenize != tokenBase) {
return 0;
return state.tokenize.isString ? CodeMirror.Pass : 0;
}
return state.scopes[0].offset;
}
};
return external;
});

@ -46,7 +46,7 @@ var IPython = (function (IPython) {
var utils = IPython.utils;
var key = IPython.utils.keycodes;
CodeMirror.modeURL = "/static/codemirror/mode/%N/%N.js";
CodeMirror.modeURL = "/static/components/codemirror/mode/%N/%N.js";
/**
* A Cell conceived to write code.
@ -66,7 +66,7 @@ var IPython = (function (IPython) {
this.code_mirror = null;
this.input_prompt_number = null;
this.collapsed = false;
this.default_mode = 'python';
this.default_mode = 'ipython';
var cm_overwrite_options = {
@ -86,7 +86,7 @@ var IPython = (function (IPython) {
CodeCell.options_default = {
cm_config : {
extraKeys: {"Tab": "indentMore","Shift-Tab" : "indentLess",'Backspace':"delSpaceToPrevTabStop"},
mode: 'python',
mode: 'ipython',
theme: 'ipython',
matchBrackets: true
}

@ -0,0 +1,345 @@
// This is an ipython mode for CodeMirror. We started from the CM Python mode and renamed
// it to ipython. We have then marked all other changes we have made to the file.
CodeMirror.defineMode("ipython", function(conf, parserConf) {
var ERRORCLASS = 'error';
function wordRegexp(words) {
return new RegExp("^((" + words.join(")|(") + "))\\b");
}
// IPython-specific changes: add '?' as recognized character using \\?
var singleOperators = parserConf.singleOperators || new RegExp("^[\\+\\-\\*/%&|\\^~<>!\\?]");
// End IPython changes.
var singleDelimiters = parserConf.singleDelimiters || new RegExp('^[\\(\\)\\[\\]\\{\\}@,:`=;\\.]');
var doubleOperators = parserConf.doubleOperators || new RegExp("^((==)|(!=)|(<=)|(>=)|(<>)|(<<)|(>>)|(//)|(\\*\\*))");
var doubleDelimiters = parserConf.doubleDelimiters || new RegExp("^((\\+=)|(\\-=)|(\\*=)|(%=)|(/=)|(&=)|(\\|=)|(\\^=))");
var tripleDelimiters = parserConf.tripleDelimiters || new RegExp("^((//=)|(>>=)|(<<=)|(\\*\\*=))");
var identifiers = parserConf.identifiers|| new RegExp("^[_A-Za-z][_A-Za-z0-9]*");
var wordOperators = wordRegexp(['and', 'or', 'not', 'is', 'in']);
var commonkeywords = ['as', 'assert', 'break', 'class', 'continue',
'def', 'del', 'elif', 'else', 'except', 'finally',
'for', 'from', 'global', 'if', 'import',
'lambda', 'pass', 'raise', 'return',
'try', 'while', 'with', 'yield'];
var commonBuiltins = ['abs', 'all', 'any', 'bin', 'bool', 'bytearray', 'callable', 'chr',
'classmethod', 'compile', 'complex', 'delattr', 'dict', 'dir', 'divmod',
'enumerate', 'eval', 'filter', 'float', 'format', 'frozenset',
'getattr', 'globals', 'hasattr', 'hash', 'help', 'hex', 'id',
'input', 'int', 'isinstance', 'issubclass', 'iter', 'len',
'list', 'locals', 'map', 'max', 'memoryview', 'min', 'next',
'object', 'oct', 'open', 'ord', 'pow', 'property', 'range',
'repr', 'reversed', 'round', 'set', 'setattr', 'slice',
'sorted', 'staticmethod', 'str', 'sum', 'super', 'tuple',
'type', 'vars', 'zip', '__import__', 'NotImplemented',
'Ellipsis', '__debug__'];
var py2 = {'builtins': ['apply', 'basestring', 'buffer', 'cmp', 'coerce', 'execfile',
'file', 'intern', 'long', 'raw_input', 'reduce', 'reload',
'unichr', 'unicode', 'xrange', 'False', 'True', 'None'],
'keywords': ['exec', 'print']};
var py3 = {'builtins': ['ascii', 'bytes', 'exec', 'print'],
'keywords': ['nonlocal', 'False', 'True', 'None']};
if (!!parserConf.version && parseInt(parserConf.version, 10) === 3) {
commonkeywords = commonkeywords.concat(py3.keywords);
commonBuiltins = commonBuiltins.concat(py3.builtins);
var stringPrefixes = new RegExp("^(([rb]|(br))?('{3}|\"{3}|['\"]))", "i");
} else {
commonkeywords = commonkeywords.concat(py2.keywords);
commonBuiltins = commonBuiltins.concat(py2.builtins);
var stringPrefixes = new RegExp("^(([rub]|(ur)|(br))?('{3}|\"{3}|['\"]))", "i");
}
var keywords = wordRegexp(commonkeywords);
var builtins = wordRegexp(commonBuiltins);
var indentInfo = null;
// tokenizers
function tokenBase(stream, state) {
// Handle scope changes
if (stream.sol()) {
var scopeOffset = state.scopes[0].offset;
if (stream.eatSpace()) {
var lineOffset = stream.indentation();
if (lineOffset > scopeOffset) {
indentInfo = 'indent';
} else if (lineOffset < scopeOffset) {
indentInfo = 'dedent';
}
return null;
} else {
if (scopeOffset > 0) {
dedent(stream, state);
}
}
}
if (stream.eatSpace()) {
return null;
}
var ch = stream.peek();
// Handle Comments
if (ch === '#') {
stream.skipToEnd();
return 'comment';
}
// Handle Number Literals
if (stream.match(/^[0-9\.]/, false)) {
var floatLiteral = false;
// Floats
if (stream.match(/^\d*\.\d+(e[\+\-]?\d+)?/i)) { floatLiteral = true; }
if (stream.match(/^\d+\.\d*/)) { floatLiteral = true; }
if (stream.match(/^\.\d+/)) { floatLiteral = true; }
if (floatLiteral) {
// Float literals may be "imaginary"
stream.eat(/J/i);
return 'number';
}
// Integers
var intLiteral = false;
// Hex
if (stream.match(/^0x[0-9a-f]+/i)) { intLiteral = true; }
// Binary
if (stream.match(/^0b[01]+/i)) { intLiteral = true; }
// Octal
if (stream.match(/^0o[0-7]+/i)) { intLiteral = true; }
// Decimal
if (stream.match(/^[1-9]\d*(e[\+\-]?\d+)?/)) {
// Decimal literals may be "imaginary"
stream.eat(/J/i);
// TODO - Can you have imaginary longs?
intLiteral = true;
}
// Zero by itself with no other piece of number.
if (stream.match(/^0(?![\dx])/i)) { intLiteral = true; }
if (intLiteral) {
// Integer literals may be "long"
stream.eat(/L/i);
return 'number';
}
}
// Handle Strings
if (stream.match(stringPrefixes)) {
state.tokenize = tokenStringFactory(stream.current());
return state.tokenize(stream, state);
}
// Handle operators and Delimiters
if (stream.match(tripleDelimiters) || stream.match(doubleDelimiters)) {
return null;
}
if (stream.match(doubleOperators)
|| stream.match(singleOperators)
|| stream.match(wordOperators)) {
return 'operator';
}
if (stream.match(singleDelimiters)) {
return null;
}
if (stream.match(keywords)) {
return 'keyword';
}
if (stream.match(builtins)) {
return 'builtin';
}
if (stream.match(identifiers)) {
return 'variable';
}
// Handle non-detected items
stream.next();
return ERRORCLASS;
}
function tokenStringFactory(delimiter) {
while ('rub'.indexOf(delimiter.charAt(0).toLowerCase()) >= 0) {
delimiter = delimiter.substr(1);
}
var singleline = delimiter.length == 1;
var OUTCLASS = 'string';
function tokenString(stream, state) {
while (!stream.eol()) {
stream.eatWhile(/[^'"\\]/);
if (stream.eat('\\')) {
stream.next();
if (singleline && stream.eol()) {
return OUTCLASS;
}
} else if (stream.match(delimiter)) {
state.tokenize = tokenBase;
return OUTCLASS;
} else {
stream.eat(/['"]/);
}
}
if (singleline) {
if (parserConf.singleLineStringErrors) {
return ERRORCLASS;
} else {
state.tokenize = tokenBase;
}
}
return OUTCLASS;
}
tokenString.isString = true;
return tokenString;
}
function indent(stream, state, type) {
type = type || 'py';
var indentUnit = 0;
if (type === 'py') {
if (state.scopes[0].type !== 'py') {
state.scopes[0].offset = stream.indentation();
return;
}
for (var i = 0; i < state.scopes.length; ++i) {
if (state.scopes[i].type === 'py') {
indentUnit = state.scopes[i].offset + conf.indentUnit;
break;
}
}
} else {
indentUnit = stream.column() + stream.current().length;
}
state.scopes.unshift({
offset: indentUnit,
type: type
});
}
function dedent(stream, state, type) {
type = type || 'py';
if (state.scopes.length == 1) return;
if (state.scopes[0].type === 'py') {
var _indent = stream.indentation();
var _indent_index = -1;
for (var i = 0; i < state.scopes.length; ++i) {
if (_indent === state.scopes[i].offset) {
_indent_index = i;
break;
}
}
if (_indent_index === -1) {
return true;
}
while (state.scopes[0].offset !== _indent) {
state.scopes.shift();
}
return false;
} else {
if (type === 'py') {
state.scopes[0].offset = stream.indentation();
return false;
} else {
if (state.scopes[0].type != type) {
return true;
}
state.scopes.shift();
return false;
}
}
}
function tokenLexer(stream, state) {
indentInfo = null;
var style = state.tokenize(stream, state);
var current = stream.current();
// Handle '.' connected identifiers
if (current === '.') {
style = stream.match(identifiers, false) ? null : ERRORCLASS;
if (style === null && state.lastToken === 'meta') {
// Apply 'meta' style to '.' connected identifiers when
// appropriate.
style = 'meta';
}
return style;
}
// Handle decorators
if (current === '@') {
return stream.match(identifiers, false) ? 'meta' : ERRORCLASS;
}
if ((style === 'variable' || style === 'builtin')
&& state.lastToken === 'meta') {
style = 'meta';
}
// Handle scope changes.
if (current === 'pass' || current === 'return') {
state.dedent += 1;
}
if (current === 'lambda') state.lambda = true;
if ((current === ':' && !state.lambda && state.scopes[0].type == 'py')
|| indentInfo === 'indent') {
indent(stream, state);
}
var delimiter_index = '[({'.indexOf(current);
if (delimiter_index !== -1) {
indent(stream, state, '])}'.slice(delimiter_index, delimiter_index+1));
}
if (indentInfo === 'dedent') {
if (dedent(stream, state)) {
return ERRORCLASS;
}
}
delimiter_index = '])}'.indexOf(current);
if (delimiter_index !== -1) {
if (dedent(stream, state, current)) {
return ERRORCLASS;
}
}
if (state.dedent > 0 && stream.eol() && state.scopes[0].type == 'py') {
if (state.scopes.length > 1) state.scopes.shift();
state.dedent -= 1;
}
return style;
}
var external = {
startState: function(basecolumn) {
return {
tokenize: tokenBase,
scopes: [{offset:basecolumn || 0, type:'py'}],
lastToken: null,
lambda: false,
dedent: 0
};
},
token: function(stream, state) {
var style = tokenLexer(stream, state);
state.lastToken = style;
if (stream.eol() && stream.lambda) {
state.lambda = false;
}
return style;
},
indent: function(state) {
if (state.tokenize != tokenBase) {
return state.tokenize.isString ? CodeMirror.Pass : 0;
}
return state.scopes[0].offset;
}
};
return external;
});
CodeMirror.defineMIME("text/x-ipython", "ipython");

@ -198,8 +198,8 @@ var IPython = (function (IPython) {
// TODO: I propose to remove enough horizontal pixel
// to align the text later
this.complete.css('left', pos.x + 'px');
this.complete.css('top', pos.yBot + 'px');
this.complete.css('left', pos.left + 'px');
this.complete.css('top', pos.bottom + 'px');
this.complete.append(this.sel);
$('body').append(this.complete);

@ -292,27 +292,21 @@ var IPython = (function (IPython) {
// whatever anchor/head order but arrow at mid x selection
var anchor = this.code_mirror.cursorCoords(false);
var head = this.code_mirror.cursorCoords(true);
var pos = {};
pos.y = head.y
pos.yBot = head.yBot
pos.x = (head.x+anchor.x)/2;
var xinit = pos.x;
var xinit = (head.left+anchor.left)/2;
var xinter = o.left + (xinit - o.left) / w * (w - 450);
var posarrowleft = xinit - xinter;
if (this._hidden == false) {
this.tooltip.animate({
'left': xinter - 30 + 'px',
'top': (pos.yBot + 10) + 'px'
'top': (head.bottom + 10) + 'px'
});
} else {
this.tooltip.css({
'left': xinter - 30 + 'px'
});
this.tooltip.css({
'top': (pos.yBot + 10) + 'px'
'top': (head.bottom + 10) + 'px'
});
}
this.arrow.animate({

@ -367,16 +367,29 @@ div.text_cell_render {
.CodeMirror {
line-height: 1.231; /* Changed from 1em to our global default */
height: auto; /* Changed to auto to autogrow */
background: none; /* Changed from white to allow our bg to show through */
}
.CodeMirror-scroll {
height: auto; /* Changed to auto to autogrow */
/* The CodeMirror docs are a bit fuzzy on if overflow-y should be hidden or visible.*/
/* We have found that if it is visible, vertical scrollbars appear with font size changes.*/
overflow-y: hidden;
overflow-x: auto; /* Changed from auto to remove scrollbar */
}
.CodeMirror-lines {
/* In CM2, this used to be 0.4em, but in CM3 it went to 4px. We need the em value because */
/* we have set a different line-height and want this to scale with that. */
padding: 0.4em;
}
.CodeMirror pre {
/* In CM3 this went to 4px from 0 in CM2. We need the 0 value because of how we size */
/* .CodeMirror-lines */
padding: 0;
}
/* CSS font colors for translated ANSI colors. */

@ -117,6 +117,8 @@
};
position: absolute;
z-index: 2;
}
.pretooltiparrow {

@ -11,7 +11,7 @@
window.mathjax_url = "{{mathjax_url}}";
</script>
<link rel="stylesheet" href="{{ static_url("codemirror/lib/codemirror.css") }}">
<link rel="stylesheet" href="{{ static_url("components/codemirror/lib/codemirror.css") }}">
<link rel="stylesheet" href="{{ static_url("css/codemirror-ipython.css") }}">
<link rel="stylesheet" href="{{ static_url("prettify/prettify.css") }}"/>
@ -184,16 +184,16 @@ class="notebook_app"
{{super()}}
<script src="{{ static_url("codemirror/lib/codemirror.js") }}" charset="utf-8"></script>
<script src="{{ static_url("codemirror/lib/util/loadmode.js") }}" charset="utf-8"></script>
<script src="{{ static_url("codemirror/lib/util/multiplex.js") }}" charset="utf-8"></script>
<script src="{{ static_url("codemirror/mode/python/python.js") }}" charset="utf-8"></script>
<script src="{{ static_url("codemirror/mode/htmlmixed/htmlmixed.js") }}" charset="utf-8"></script>
<script src="{{ static_url("codemirror/mode/xml/xml.js") }}" charset="utf-8"></script>
<script src="{{ static_url("codemirror/mode/javascript/javascript.js") }}" charset="utf-8"></script>
<script src="{{ static_url("codemirror/mode/css/css.js") }}" charset="utf-8"></script>
<script src="{{ static_url("codemirror/mode/rst/rst.js") }}" charset="utf-8"></script>
<script src="{{ static_url("codemirror/mode/markdown/markdown.js") }}" charset="utf-8"></script>
<script src="{{ static_url("components/codemirror/lib/codemirror.js") }}" charset="utf-8"></script>
<script src="{{ static_url("components/codemirror/addon/mode/loadmode.js") }}" charset="utf-8"></script>
<script src="{{ static_url("components/codemirror/addon/mode/multiplex.js") }}" charset="utf-8"></script>
<script src="{{ static_url("js/codemirror-ipython.js") }}" charset="utf-8"></script>
<script src="{{ static_url("components/codemirror/mode/htmlmixed/htmlmixed.js") }}" charset="utf-8"></script>
<script src="{{ static_url("components/codemirror/mode/xml/xml.js") }}" charset="utf-8"></script>
<script src="{{ static_url("components/codemirror/mode/javascript/javascript.js") }}" charset="utf-8"></script>
<script src="{{ static_url("components/codemirror/mode/css/css.js") }}" charset="utf-8"></script>
<script src="{{ static_url("components/codemirror/mode/rst/rst.js") }}" charset="utf-8"></script>
<script src="{{ static_url("components/codemirror/mode/markdown/markdown.js") }}" charset="utf-8"></script>
<script src="{{ static_url("pagedown/Markdown.Converter.js") }}" charset="utf-8"></script>

Loading…
Cancel
Save