-
-
Save alexbaumgertner/f89986470a6f201358a83b3269dc574a to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
(function(global) { | |
/** | |
* Возвращает регулярное выражение для открывающего и закрывающего тега | |
* Открывающий тег может быть с любыми классами, свойствами и тд., | |
* например: `<i class="delete" tabindex="1" foo="bar">` | |
* | |
* @param {String} tag Название тега | |
* | |
* @returns {RegExp} | |
*/ | |
function getTagRegExp(tag) { | |
if (!tag || tag.length < 1) { | |
return false; | |
} | |
return new RegExp('<\/?' + tag + '[^>]*>', 'g'); | |
} | |
if (!String.prototype.repeat) { | |
/** | |
* Полифил для повтора строки | |
* | |
* @param count | |
* @returns {string} | |
*/ | |
String.prototype.repeat = function(count) { | |
'use strict'; | |
if (this == null) { | |
throw new TypeError('can\'t convert ' + this + ' to object'); | |
} | |
var str = '' + this; | |
count = +count; | |
if (count != count) { | |
count = 0; | |
} | |
if (count < 0) { | |
throw new RangeError('repeat count must be non-negative'); | |
} | |
if (count == Infinity) { | |
throw new RangeError('repeat count must be less than infinity'); | |
} | |
count = Math.floor(count); | |
if (str.length == 0 || count == 0) { | |
return ''; | |
} | |
// Ensuring count is a 31-bit integer allows us to heavily optimize the | |
// main part. But anyway, most current (August 2014) browsers can't handle | |
// strings 1 << 28 chars or longer, so: | |
if (str.length * count >= 1 << 28) { | |
throw new RangeError('repeat count must not overflow maximum string size'); | |
} | |
var rpt = ''; | |
for (;;) { | |
if ((count & 1) == 1) { | |
rpt += str; | |
} | |
count >>>= 1; | |
if (count == 0) { | |
break; | |
} | |
str += str; | |
} | |
return rpt; | |
} | |
} | |
/** | |
* Отменяет обработчики для события `event` | |
* | |
* @param {Event} event | |
*/ | |
function preventEventHandler(event) { | |
event.preventDefault(); | |
event.stopPropagation(); | |
} | |
/** | |
* Мини-tooltip о действии в редакторе | |
* | |
* @param {Editor} editor | |
* @constructor | |
*/ | |
var MovieTooltip = function MovieTooltip(editor) { | |
this.editor = editor; | |
this.theme = editor.getTheme(); | |
if (this.theme) { | |
// ace/theme/monokai | |
this.theme = this.theme.split('/')[2]; | |
} | |
this.tooltip = null; | |
this.tooltipContainer = null; | |
this.fadeAwayTime = 1000; //ms | |
this.init(); | |
}; | |
/** | |
* Инициализирует tooltip с помощью `ace/line_widgets` | |
*/ | |
MovieTooltip.prototype.init = function init() { | |
var LineWidgets = ace.require("ace/line_widgets").LineWidgets; | |
this.session = this.editor.getSession(); | |
if (!this.session.widgetManager) { | |
this.session.widgetManager = new LineWidgets(this.session); | |
this.session.widgetManager.attach(this.editor); | |
} | |
this.tooltipContainer = document.createElement('div'); | |
this.tooltipContainer.classList.add('tooltip-answer'); | |
if (this.theme) { | |
this.tooltipContainer.classList.add('tooltip-answer_theme_' + this.theme); | |
} | |
this.runCallback = this.runCallback.bind(this); | |
this.tooltip = { | |
row: 0, | |
fixedWidth: true, | |
coverGutter: false, | |
el: this.tooltipContainer | |
}; | |
}; | |
/** | |
* Устанавливает текст tooltip в значение params.text и прокручивает окно к месту показа tooltip | |
* | |
* @param {Object} params | |
* @param {String} params.tooltip Текст/HTML для показа в tooltip | |
* @param {Object} params.tooltipPosition Позиция | |
* @param {Function} next Callback | |
*/ | |
MovieTooltip.prototype.show = function show(params, next) { | |
var text = params.tooltip, | |
row = this.tooltip.row = params.tooltipPosition.row, | |
firstVisibleRow = this.editor.getFirstVisibleRow(), | |
lastVisibleRow = this.editor.getLastVisibleRow(), | |
halfEditorHeight = (lastVisibleRow - firstVisibleRow) / 2; | |
// Прокрутить окно к месту редактирования | |
// editor.centerSelection() не работает, так как курсор еще на первой строке | |
if (row >= lastVisibleRow) { | |
// TODO: улучшить починку ухода текста под редактор | |
text = text + '<br><br><br>'; // чтобы текст не уходил под окно редактора | |
this.editor.scrollToRow(row + halfEditorHeight); | |
} else if (row <= firstVisibleRow) { | |
this.editor.scrollToRow(row - halfEditorHeight); | |
} | |
this.tooltipContainer.innerHTML = text; | |
this.session.widgetManager.addLineWidget(this.tooltip); | |
this.tooltipContainer.classList.add('tooltip-answer_visible'); | |
next(); | |
}; | |
/** | |
* Скрывает tooltip и удаляет его DOM-представление | |
* | |
* @param {Function} cb Вызывается после удаления | |
*/ | |
MovieTooltip.prototype.hide = function hide(cb) { | |
this.tooltipContainer.classList.remove('tooltip-answer_visible'); | |
this.callback = cb; | |
// Ждать ресета размера строки | |
this.session.on('changeFold', this.runCallback); | |
setTimeout(function() { | |
this.session.widgetManager.removeLineWidget(this.tooltip); | |
}.bind(this), this.fadeAwayTime); | |
}; | |
MovieTooltip.prototype.runCallback = function() { | |
this.session.off('changeFold', this.runCallback); | |
this.callback(); | |
}; | |
/** | |
* Класс Показа подсказки | |
* | |
* @constructor | |
* @param {Editor} editor Экземпляр редактора Ace | |
* @param {Number} actionRunInterval Шаг показа подсказок, ms | |
*/ | |
var Movie = function(editor, actionRunInterval) { | |
this.logData = { | |
isFirstRun: true, | |
startDate: new Date(), | |
skipMethods: ['addAction'] | |
}; | |
// Интервал ввода символа | |
this.codeTypeInterval = 130; // ms | |
// в n раз ускоряется показ действия на одной линии | |
this.sameLineActionBooster = 10; | |
this.actionRunInterval = actionRunInterval; | |
// DOM-элемент, к верхней границе которого будет прокручено окно браузера при старте показа | |
this.windowScrollTo = '#preview'; | |
/** @type Editor */ | |
this.editor = editor; | |
// @type {jQuery} Editor jQuery wrapped DOM node | |
this.$editorContainer = $(editor.container); | |
this.editorOffset = this.$editorContainer.offset(); | |
// @type {Object} Сохраненные настройки редактора | |
// @private | |
this._editor_saved_options = {}; | |
/* Доопределение метода find Редактора (начало) */ | |
var oldEditorFind = this.editor.find; | |
this.editor.find = function() { | |
var found = oldEditorFind.apply(this.editor, arguments); | |
this.editor._emit('cursor:positioned', editor, found); | |
return found; | |
}.bind(this); | |
/* Доопределение метода find Редактора (конец) */ | |
this.actions = []; | |
this.tooltip = new MovieTooltip(this.editor); | |
this.typeCodeAsync = this.typeCodeAsync.bind(this); | |
this.setMotions(); | |
}; | |
/** | |
* Составляет сценарий показа подсказки | |
* | |
* @param {Object} data Данные для показа подсказки | |
* Пример данных: | |
{ | |
tooltip: 'Оберните в отдельный тег <code>p</code> каждое предложение после заголовка.', | |
actions: [ | |
{ | |
action: 'add', | |
cursorPosition: {row: 9, column: 8}, | |
to: '<p>' | |
}, | |
{ | |
action: 'add', | |
cursorPosition: {row: 9, column: 57}, | |
to: '</p>' | |
} | |
] | |
} | |
* | |
* @param {Object|Array} data.actions Действия показа подсказки | |
*/ | |
Movie.prototype.setData = function setData(data) { | |
this.log({ | |
name: 'setData', | |
args: { | |
data: data | |
} | |
}); | |
this.tooltipText = data.tooltip; | |
if (!Array.isArray(data.actions)) { | |
data.actions = [data.actions]; | |
} | |
data.actions.forEach(function(action) { | |
action.tooltip = action.tooltip || data.tooltip; | |
this.addAction(action); | |
}, this); | |
}; | |
/** | |
* Добавляет действие в сценарий показа подсказки | |
* | |
* @param {Object} data Данные для действия | |
* Пример данных: | |
{ | |
action: 'add', | |
tooltip: 'Оберните в отдельный тег <code>p</code> каждое предложение после заголовка.', | |
cursorPosition: {row: 9, column: 8}, | |
to: '<p>' | |
} | |
* | |
* | |
* @returns {Movie} Экземляр подсказки, для chain | |
*/ | |
Movie.prototype.addAction = function addAction(data) { | |
this.log({ | |
name: 'addAction', | |
args: { | |
data: data | |
} | |
}); | |
this.actions.push({ | |
name: data.action, | |
data: data | |
}); | |
return this; | |
}; | |
/** | |
* Стартует показ подсказки. Выполняет callback в конце показа. | |
* | |
* @param {Function} callback выполнится вконце показа | |
*/ | |
Movie.prototype.start = function start(callback) { | |
this.log({ | |
name: 'start', | |
args: { | |
callback: callback | |
} | |
}); | |
this.editorOnMovie('start'); | |
var actionIndex = 0, | |
_this = this, | |
firstAction = _this.actions[0], | |
cursorPosition = firstAction.data.cursorPosition, | |
tooltipPosition; | |
// Выравнивание показа для определенного типа действий | |
var tooltipAdjustTop = { | |
'addLine': -1 // line | |
}; | |
tooltipPosition = { | |
row: cursorPosition.row + (tooltipAdjustTop[firstAction.name] || 0) | |
}; | |
// Показать tooltip для первого действия | |
_this.tooltip.show({ | |
tooltip: this.tooltipText, | |
tooltipPosition: tooltipPosition | |
}, | |
function() { | |
setTimeout(next, _this.actionRunInterval) | |
} | |
); | |
// Показать действие (action) подсказки, @see this.add() | |
function next() { | |
var action = _this.actions[actionIndex]; | |
// Если последний action – остановка проигрывания. | |
if (!action) { | |
_this.stop(callback); | |
// Показать текущий action | |
} else { | |
// Ускорение показа подсказки на одной линии (начало) | |
var actionRunInterval = _this.actionRunInterval, | |
currentCursorPosition = action.data.cursorPosition, | |
nextAction = _this.actions[actionIndex + 1], | |
nextCursorPosition = (nextAction && nextAction.data.cursorPosition) || {}; | |
if (nextCursorPosition.row === currentCursorPosition.row) { | |
actionRunInterval = actionRunInterval / _this.sameLineActionBooster; | |
} | |
// Ускорение показа подсказки на одной линии (конец) | |
// Показать action | |
_this.motions[action.name]( | |
action.data, | |
function() { | |
setTimeout(next, actionRunInterval); | |
} | |
); | |
} | |
actionIndex = actionIndex + 1; | |
_this.alignEditorViewport(); | |
} | |
}; | |
/** | |
* Останавливает показ подсказки | |
* | |
* @param {Function} cb Выполняется в конце показа подсказки | |
*/ | |
Movie.prototype.stop = function stop(cb) { | |
this.log({ | |
name: 'stop', | |
args: null | |
}); | |
this.editorOnMovie('stop'); | |
this.tooltip.hide(cb); | |
}; | |
/** | |
* Выравнивает вьюпорт редактора | |
* @see https://github.com/htmlacademy/htmlacademy.ru/issues/593 | |
*/ | |
Movie.prototype.alignEditorViewport = function alignEditorViewport() { | |
var lineVisibleGap = 4 // кол-во видимых линий сверху и снизу от текущей | |
, | |
currentRow = (this.editor.getCursorPosition()).row, | |
firstVisibleRow = this.editor.getFirstVisibleRow() + 1 // в редакторе счет строк с 0 | |
, | |
lastVisibleRow = this.editor.getLastVisibleRow() + 1; // тоже | |
if (currentRow <= firstVisibleRow) { // Верхняя строка редактора | |
this.editor.scrollToRow(firstVisibleRow - lineVisibleGap); | |
} else if (currentRow >= lastVisibleRow) { // Нижная строка редактора | |
this.editor.scrollToRow(currentRow + lineVisibleGap); | |
} | |
}; | |
/** | |
* Добавляет действия к показу Подсказок (начало) | |
*/ | |
Movie.prototype.setMotions = function() { | |
var _this = this; | |
/** @type {Object} */ | |
_this.motions = { | |
/** | |
* Заменяет посимвольно текст с data.from на data.to | |
* | |
* @param {Object} data | |
* @param {String} data.from Текст, который нужно заменить | |
* @param {String} data.to Замена для текста data.from | |
* @param {Function} next Callback | |
*/ | |
replace: function(data, next) { | |
_this.log({ | |
name: 'motions.replace', | |
args: { | |
data: data, | |
next: next | |
} | |
}); | |
var | |
// В данном случае нумерация начинается с 0, нужно вычесть единицу | |
lineFrom = data.cursorPosition.row || 0, // Заменять со строки. | |
stringFrom = data.from.string || data.from; | |
_this.editor.moveCursorToPosition(data.cursorPosition); | |
// Выбрать удаляемое... | |
var foundToReplace = _this.editor.find(stringFrom); | |
// Если нечего заменять | |
if (!foundToReplace) { | |
return next(); | |
} | |
// удалить... | |
_this.editor.remove(); | |
// ...вставить побуквенно замену. | |
_this.typeCodeAsync(data.to, next); | |
}, | |
/** | |
* Удаляет текст data.from | |
* | |
* @param {Object} data | |
* @param {String|RegExp} data.from Строка/регулярное выражение для удаления | |
* @param {Function} next Callback | |
*/ | |
'delete': function(data, next) { | |
_this.log({ | |
name: 'motions.delete', | |
args: { | |
data: data, | |
next: next | |
} | |
}); | |
_this.editor.moveCursorToPosition(data.cursorPosition); | |
// Найти строку|строку попадающую под регулярное выражение... | |
_this.editor.find(data.from); | |
// ...показать ее и удалить. | |
_this.editor.remove(); | |
next(); | |
}, | |
/** | |
* Добавляет строку кода | |
* | |
* @param {Object} data | |
* @param {Object} data.cursorPosition | |
* @param {Number} data.cursorPosition.row Номер строки для добавления кода | |
* @param {String} data.to Добавляемый код | |
* @param {Function} next Callback | |
*/ | |
addLine: function(data, next) { | |
_this.log({ | |
name: 'motions.addLine', | |
args: { | |
data: data, | |
next: next | |
} | |
}); | |
var | |
row = data.cursorPosition.row, | |
column = data.cursorPosition.column, | |
indent = (' ').repeat(column); | |
// Вычитаем 1, в данном случае нумерация начинается с 0 | |
var newLinePos = _this.editor.getSession().doc | |
.insertMergedLines({ | |
row: row - 1 | |
}, ['', indent]); | |
// Поставить курсор на добавленную строку | |
_this.editor.moveCursorToPosition(newLinePos); | |
_this.typeCodeAsync(data.to, next); | |
}, | |
/** | |
* Добавляет код | |
* | |
* @param {Object} data | |
* @param {Object} data.cursorPosition | |
* @param {Number} data.cursorPosition.row Номер строки для добавления кода | |
* @param {String} data.to Добавляемый код | |
* @param {Function} next Callback | |
*/ | |
add: function(data, next) { | |
_this.log({ | |
name: 'motions.add', | |
args: { | |
data: data, | |
next: next | |
} | |
}); | |
// Поставить курсор на добавленную строку. | |
_this.editor.moveCursorToPosition(data.cursorPosition); | |
// Вставить добавляемый код | |
_this.typeCodeAsync(data.to, next); | |
} | |
}; | |
}; | |
// Добавляет действия к показу Подсказок (конец) | |
/** | |
* Блокирует курсор редактора от перемещения. | |
* | |
* https://github.com/ajaxorg/ace/issues/266#issuecomment-16351550 | |
* | |
* @param {Boolean} value Заморозить курсор в случае true | |
*/ | |
Movie.prototype.setCursorFreezing = function setCursorFreezing(value) { | |
var ace_content = this.$editorContainer.find('.ace_content'); | |
if (value) { | |
ace_content.on('mousedown keydown', preventEventHandler); | |
} else { | |
ace_content.off('mousedown keydown', preventEventHandler); | |
} | |
return this; | |
}; | |
/** | |
* Отключает автозакрытие тега в редакторе | |
* | |
* @returns {Movie} | |
*/ | |
Movie.prototype.disableEditorAutoCloseTags = function disableEditorAutoCloseTags() { | |
var $behaviour = this.editor.getSession().getMode().$behaviour; | |
this._editor_saved_options.autoclosing = $behaviour.$behaviours.autoclosing; | |
$behaviour.remove("autoclosing"); | |
return this; | |
}; | |
/** | |
* Восстанавливает настройку автозакрытия тега в редакторе | |
* | |
* @returns {Movie} | |
*/ | |
Movie.prototype.restoreEditorAutoCloseTags = function restoreEditorAutoCloseTags() { | |
var $behaviour = this.editor.getSession().getMode().$behaviour, | |
savedAutoclosing = this._editor_saved_options.autoclosing; | |
if (savedAutoclosing) { | |
$behaviour.$behaviours.autoclosing = savedAutoclosing; | |
} | |
return this; | |
}; | |
/** | |
* Настраивает редактор в соот-вии с действием Подсказки | |
* | |
* @param {String} action Название действия Movie | |
*/ | |
Movie.prototype.editorOnMovie = function editorOnMovie(action) { | |
switch (action) { | |
case 'start': | |
this.setCursorFreezing(true) | |
.disableEditorAutoCloseTags(); | |
break; | |
case 'stop': | |
this.setCursorFreezing(false) | |
.restoreEditorAutoCloseTags(); | |
break; | |
} | |
}; | |
/** | |
* Печатает код посимвольно. | |
* Метод работает асинхронно. | |
* | |
* @param {String} code | |
* @param {Function} next callback | |
*/ | |
Movie.prototype.typeCodeAsync = function typeCodeAsync(code, next) { | |
this.log({ | |
name: 'typeCodeAsync', | |
args: { | |
code: code, | |
next: next | |
} | |
}); | |
var | |
isFirstType = true, | |
_this = this, | |
symbols = code.split(''), | |
char = symbols.shift(); | |
type(char); | |
function type(char) { | |
setTimeout(function() { | |
_this.editor.insert(char); | |
if (isFirstType) { | |
_this.editor._emit('cursor:positioned', _this.editor, char); | |
isFirstType = false; | |
} | |
char = symbols.shift(); | |
if (char) { | |
type(char); | |
} else { | |
typeof next === 'function' && next(); | |
} | |
}, _this.codeTypeInterval); | |
} | |
}; | |
/** | |
* Показыват лог с отметками времени | |
* | |
* @param {*} data | |
*/ | |
Movie.prototype.log = function(data) { | |
if (!global.isAceEditorMovieLog) { | |
return; | |
} | |
// Показать начало работы лога | |
if (this.logData.isFirstRun) { | |
console.log( | |
'start logging at: ', | |
this.logData.startDate.getHours() + ':' + | |
this.logData.startDate.getMinutes() + ':' + | |
this.logData.startDate.getSeconds() | |
); | |
this.logData.isFirstRun = false; | |
} | |
if ( | |
this.logData.skipMethods !== "all" && | |
this.logData.skipMethods.indexOf(data.name) === -1 | |
) { | |
console.log(Date.now() - this.logData.startDate + ' ms', data); | |
} | |
}; | |
global.aceEditorMovie || (global.aceEditorMovie = Movie) | |
}(this)); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment