Реализация undo/redo для web-страницы произвольной структуры на Javascript
Современные web-интерфейсы часто используются для редактирования настроек больших и сложных систем. Такие настройки загружаются на клиентскую часть с сервера и могут иметь сложную структуру с большим количеством полей ввода, переключателей, динамически создаваемых и удаляемых элементов.
При их редактировании могут возникать ситуации, когда после многочисленных изменений пользователю может понадобиться вернуть старое значение для какого-либо поля ввода, отменить удаление сложного элемента данных и тому подобное. Ведь если данных действительно много, то вспомнить старое значение может быть довольно трудно, а восстановление большого элемента настроек со множеством вложенных структур может занять много времени. Не говоря уже о том, что переделывание работы для многих является раздражающим фактором. А обновление настроек с сервера перетрет все локальные изменения
Что можно предложить для решения это проблемы? Самый короткий и простой путь – заявить, что пользователь «сам виноват» и это его проблемы. В крайнем случае – запрашивать подтверждение при удалении и других «необратимых» действиях. Но есть и другое решение – достаточно вспомнить любой текстовый редактор, где есть возможность отменить и/или вернуть любое выполненное действие. Для web-страницы это можно сделать единственным путем – запрограммировать такое поведение на javascript.
Итак, дано: web-страница, данные запрашиваются и сохраняются ajax-запросами к серверу. Страница имеют сложную структуру с большим количеством полей ввода/селектов, а так же динамически создаваемыми элементами.
Необходимо: получать любое состояние страницы между моментами получения и сохранения данных.
Напоминание: не следует применять это решение для заведомо простых задач. Если число значимых полей ввода исчисляется в пару десятков, а динамических изменений в их структуре нет, то это реализация этого решения будет не целесообразна.
При каждом действии на странице запоминаются 2 вещи – как отменить это действие и как вернуть отмененное действие (далее используем термины undo и redo). Действия могут быть двух типов – изменение значений полей ввода/селектов/чекбоксов, либо изменения структуры страницы (добавление/удаление DOM-элементов).
В первом случае действие считается совершенным при изменении значения элемента (например, в поле ввода изменили текст), во втором случае – при нажатии на соответствующую кнопку на странице (например, при нажатии кнопки «удалить» напротив элемента списка).
Для отображения и навигации по изменениям рисуются отдельные кнопки (например, со стрелками «назад» и «вперед»), при нажатии на которые выполняются действия undo и redo для данного состояния.
Теперь в терминах программирования
Для начала нам понадобится реализовать стэк, в котором будут храниться функции, реализующие действия. Первоначальная реализация может быть самой простой
var UndoStack = function()
{
// массив функций
this.stack = [];
// текушая запись
this.top = -1;
// максимальное число элементов
this.limit = 100;
}
Потребуются методы clear(), push(func), pop(). Смысл их в следующем –
clear() - очищает содержимое стэка,
push(func) - добавляет на вершину стэка новую функцию (размер стэка ограничен, поэтому при достижении порога this.limit нужно замещать первый элемент) и при необходимости увеличивает this.top,
pop() - выполняет функцию, находящуюся на вершине стэка и уменьшает this.top.
Сами стэки undo и redo будут использовать одну и ту же реализацию
var _undo = new UndoStack();
var _redo = new UndoStack();
Логично реализовать их в одном элементе, например, в двух ячейках таблицы. Понадобятся изображения неактивных кнопок вперед/назад и активных кнопок вперед/назад. Сразу сделаем задел на будущее – учтем возможность размещения на одной странице нескольких таких кнопок (например, часть структуры настроек отображается в диалоговом окне), добавив соответствующие поля с удобными значениями по умолчанию.
var UndoRedoButtons = function()
{
// изображения
this.undo = …
this.redo = …
this.undodisabled = …
this.redodisabled = …
// родитель для размещения таблицы на странице
this.canvas = null;
// id по умолчанию
this.id = 'undoredotable';
// стеки по умолчанию
this.undoStack = _undo;
this.redoStack = _redo;
}
var _undoRedoButton = new UndoRedoButtons();
Понадобятся следующие методы:
UndoRedoButtons.prototype.reset = function(id, undo, redo) // создает таблицу
{
if (typeof id != 'undefined')
{
this.id = id;
this.undoStack = undo;
this.redoStack = redo;
}
// undo
var undoTd = // создаем новый элемент
var _this = this;
// undo
if (this.undoStack.empty())
// помещаем в undoTd картинку из this.undodisabled
else
{
// помещаем в undoTd картинку из this.undo
undoTd.onclick = function()
{
_this.undoStack.pop();
_this.reload(id, undo, redo);
}
}
// аналогично для redo
// создаем и возвращаем таблицу с этими ячейками, поле id установим в this.id
}
UndoRedoButtons.prototype.сreate = function(id, undo, redo) // нарисовать кнопки
{
// создаем таблицу по this.reset(id, undo, redo)
this.remove();
// добавляем созданную таблицу как потомка к this.canvas
}
UndoRedoButtons.prototype.reload = function(id, undo, redo) // обновить кнопки
{
// если существует таблица this.id
// создаем таблицу по this.reset(id, undo, redo)
// изменяем содержимое текущей таблицы this.id на созданное
}
UndoRedoButtons.prototype.remove = function()
{
// удаляет таблицу this.id
}
Мы подошли к еще одному ключевому моменту – как обратиться к элементу из функции redo? Казалось бы, тут можно использовать замыкание из основной функции, и все будет работать как задумывалось. Но это будет работать до тех пор, пока мы не столкнемся с redo для добавления новой структуры.
Рассмотрим такую последовательность действий: добавление элемента списка, изменение поля ввода внутри него, undo, undo, redo, redo.
При первом undo происходит изменение значения поля ввода на старое, при втором undo – удаление последнего элемента в списке (в предположении, что добавление элемента списка добавляет новый элемент в конец), далее redo – добавление элемента списка (вот подводный камень – это уже новый элемент, хоть и внешне идентичный с ранее созданным), и после этого изменение значения поля ввода. Если в этот момент мы будем пытаться обратиться к элементу с использованием замыкания, то ничего не получится – ведь в замыкании функции redo хранится контекст, в котором поле ввода принадлежало первому элементу списка, а тот элемент списка был удален, и вместо него был добавлен новый. При любой попытке обратиться к такой переменной мы получим ошибку.
Решение указанной проблемы может быть следующим – каждому элементу на странице ставятся в соответствие уникальные «координаты», которые позволяют однозначно его определить. В качестве таких координат можно использовать следующий объект:
{
// head (DOM) – элемент на странице, в который помещен редактор
// node (int) – номер узла в редакторе (это уже особенности реализации, зависящие от конкретных наборов данных)
// param (xpath) – путь к элементу внутри узла
}
Необходимо реализовать следующие функции
getElemPosition(elem) // возвращает координаты элемента
getElemByPosition(pos) // возвращает элемент по координатам. Реализовать эту функцию будет достаточно просто
var node = pos.head.childNodes[pos.node],
elem = $(node).find(pos.param);
Самое сложное на данном шаге – сделать правильную генерацию xpath в функции getElemPosition. Его структура может повторять структуру данных, и скорее всего будет использовать те же имена. Для списков будет необходимо указывать порядковый номер элемента в списке.
Если же возникнут коллизии при выполнении getElemByPosition, то можно попробовать их разрешить.
if (elem.length > 1)
{
var decisions = []
for (var i = 0; i < elem.length; i++)
if (getElemPosition(elem[i]).param == pos.param)
decisions.push(elem[i])
}
Если и дальше вариантов несколько, то следует повторно обратить внимание на генерацию xpath в функции getElemPosition.
Для элементов checkbox/select все работает аналогично. Единственное, undoRedoSelect должен вызывать внутри select.onchange
Теперь можно перейти к самим элементам. Для примера рассмотрим поле ввода (input)
Для каждого создаваемого элемента добавим следующие события
input.onfocus = function()
{
// запомним текущее значение в this.oldValue
}
input.onblur = function()
{
// если изменений не было, то ничего не делаем
if (this.value == this.oldValue)
return;
undoRedoInput(input, this.value, this.oldValue);
}
Теперь реализуем запоминание действий
var undoRedoInput = function(canvas, value, oldValue)
{
// значения
var newVal = value,
prevVal = oldValue,
// само поле ввода
elem = canvas,
// запомним расположение элемента
pos = getElemPosition(elem);
_undo.push(function()
{
// значения
var newVal2 = prevVal;
var prevVal2 = elem.value;
_redo.push(function()
{
undoRedoInput(getElemByPosition(pos), prevVal2, newVal2);
})
setInputValue(elem, prevVal)
})
setInputValue(canvas, newVal)
_undoRedoButton.reload();
}
var setInputValue = function(elem, value)
{
elem.value = elemOldvalue = value;
}
Перейдем к действиям, связанным с добавлением/удалением элементов списка. Важно понимать, что undo такого действия не всегда совпадает с обратным действием. Действительно, undo для удаления элемента списка – это вставка того же элемента на ту же самую позицию, а не добавление элемента. Поэтому не всегда получится использовать обратную функцию при вызове undo.
Для добавления элемента списка все прозрачно:
var addListElement = function(position, addValue, listElement)
{
var parentCanvas = getElemByPosition(position).firstChild.firstChild.lastChild
if (typeof listElement != 'undefined')
// добавление уже готового listElement в parentCanvas
else
// создание нового элемента списка по addValue и добавление его в parentCanvas
}
В обработчике click кнопки добавления элемента списка будет функция
var addListElementFunc = function(position, addValue, listElement)
{
// undo-redo
var elem = this.getElemByPosition(position),
num = elem.firstChild.firstChild.lastChild.childNodes.length,
status = this.getServiceStatus(elem);
_undo.push(function()
{
var listElement = getElemByPosition(position).firstChild.firstChild.lastChild.childNodes[num]
delListElement(position, num)
_redo.push(function()
{
addListElementFunc(position, addValue, listElement);
})
})
_this.addListElement(position, addValue, listElement);
_undoRedoButton.reload();
}
Наличие .firstChild.firstChild.lastChild после нахождения координат списка говорит о том, что его элементы не являются его прямыми потомками. Это особенности реализации.
При удалении немного сложнее:
var delListElement = function(position, elemNum)
{
var elem = this.getElemByPosition(position).firstChild.firstChild.lastChild.childNodes[elemNum];
elem.removeNode(true);
}
В обработчике click кнопки удаления элемента списка будет функция
var delListElementFunc = function(position, elemNum)
{
var listElement = this.getElemByPosition(position).firstChild.firstChild.lastChild.childNodes[elemNum]
_undo.push(function()
{
// вставка удаленного элемента на старую позицию
// (индекс, элемент, родитель)
insertChild(elemNum, listElement, _this.getElemByPosition(position).firstChild.firstChild.lastChild)
_redo.push(function()
{
delListElementFunc(position, elemNum);
})
})
_this.delListElement(position, elemNum);
_undoRedoButton.reload();
}
Для того, чтобы все встало на свои месте, осталось добавить на страницу сами кнопки
_undoRedoButton.setCanvas(<элемент на странице>);
_undoRedoButton.create();
и обновлять стеки функций и перерисовывать кнопки при сохранении
_undo.clear();
_redo.clear();
_undoRedoButton.reload();
На этом основная часть закончена.
Далее, если будет интерес, расскажу о том, как сделать подсказки, отображающие описание выполняемых действий, а так же о способе реализовать этот механизм для промежуточных асинхронных проверок перед выполнениями действий.
И напоследок небольшая демонстрация
http://www.youtube.com/watch?v=N7CzA5kQGiE
|
Классная вещь! По всему гуглу искал, но не нашел. Как приятно, что хоть кто-то смог объяснить принцип действия. Огромое спасибо!
Огромное спасибо за статью! Очень доходчиво все описано, аффтар МОЛОДЕЦ!