Skip to content

Instantly share code, notes, and snippets.

@alexbaumgertner
Last active December 15, 2017 09:34
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save alexbaumgertner/f89986470a6f201358a83b3269dc574a to your computer and use it in GitHub Desktop.
Save alexbaumgertner/f89986470a6f201358a83b3269dc574a to your computer and use it in GitHub Desktop.
(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