Javascript.RU

Реализация 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

+3

Автор: Apollo_440, дата: 26 августа, 2012 - 13:27
#permalink

Классная вещь! По всему гуглу искал, но не нашел. Как приятно, что хоть кто-то смог объяснить принцип действия. Огромое спасибо!


Автор: Гость (не зарегистрирован), дата: 3 января, 2015 - 18:32
#permalink

Огромное спасибо за статью! Очень доходчиво все описано, аффтар МОЛОДЕЦ!


Автор: Гость (не зарегистрирован), дата: 16 апреля, 2022 - 02:03
#permalink

Отправить комментарий

Приветствуются комментарии:
  • Полезные.
  • Дополняющие прочитанное.
  • Вопросы по прочитанному. Именно по прочитанному, чтобы ответ на него помог другим разобраться в предмете статьи. Другие вопросы могут быть удалены.
    Для остальных вопросов и обсуждений есть форум.
P.S. Лучшее "спасибо" - не комментарий, как все здорово, а рекомендация или ссылка на статью.
Содержание этого поля является приватным и не предназначено к показу.
  • Адреса страниц и электронной почты автоматически преобразуются в ссылки.
  • Разрешены HTML-таги: <strike> <a> <em> <strong> <cite> <code> <ul> <ol> <li> <dl> <dt> <dd> <u> <i> <b> <pre> <img> <abbr> <blockquote> <h1> <h2> <h3> <h4> <h5> <p> <div> <span> <sub> <sup>
  • Строки и параграфы переносятся автоматически.
  • Текстовые смайлы будут заменены на графические.

Подробнее о форматировании

CAPTCHA
Антиспам
10 + 3 =
Введите результат. Например, для 1+3, введите 4.
 
Поиск по сайту
Другие записи этого автора
Больше записей нет. Прокомментируйте эту запись - может быть, тогда он что-нибудь еще хорошее напишет ;)
Содержание

Учебник javascript

Основные элементы языка

Сундучок с инструментами

Интерфейсы

Все об AJAX

Оптимизация

Разное

Дерево всех статей

Популярные таги
Последние комментарии
Последние темы на форуме
Forum