WYSIWYG HTML редактор в браузере. Часть 3

Автор оригинала: OLAV JUNKER KJÆR
  • Перевод
В статье описана практика использования свойств designMode и contentEditable, а так же сопутствующих API на примере создания простого текстового редактора.
Перевод первой статьи серии, рассматривающей теорию использования designMode и contentEditable и сопутствующих API: Часть 1. Часть 2.

Введение

В первой части статьи я детально рассмотрел теорию создания браузерного редактора с использованием свойств designMode и contentEditable. Эти свойства DOM стандартизированы в HTML 5 и более-менее поддерживаются в большинстве браузеров. Во второй части статьи я перейду от теории к практике, рассмотрев создание простого кроссбраузерного текстового редактора.
Вы можете увидеть законченную версию редактора в сети и скачать его код. Листинги показывают только наиболее интересные части кода, которые требуют пояснений, остальной код не рассматривается, так как скучен. Код разделен на три файла:
  • editor.js: Основная инфраструктура приложения.
  • editlib.js: Набор функций для модификации выделений
  • util.js: Библиотека функций общего назначения.

Фрейм

Как основу мы будем использовать пустую страницу внутри IFrame'а:
<iframe id="editorFrame" src="blank.html"></iframe>
Мы можем использовать about:blank что бы получить абсолютно пустую страницу, без элементов внутри body, но я предпочел создать свою «пустую» страницу, так как это позволит нам начать работу с пустым параграфом в body.
<title></title>
<body><p></p></body>
Это предпочтительно, так как Mozilla начинает ввод текста в пустой p, как и все остальные браузеры. Если этого не сделать она вводит текст непосредственно в body. Используя свойство contentEditable мы можем обойтись и без фрейма, но Firefox 2 не поддерживает contentEditable, так что лучше все же использовать iFrame. (Примечание переводчика: FF2 мягко говоря не актуален. Так что, думаю, iframe никому больше не нужен.)

Установка режима редактирования

Мы включаем режим редактирования, когда загружается страничка, с помощью функции (находящейся в editor.js)
function createEditor() {
  var editFrame = document.getElementById("editorFrame");
  editFrame.contentWindow.document.designMode="on";
}
  bindEvent(window, "load", createEditor);
bindEvent это функция отвечающая за привязку функции к событию (определенная в util.js). Фреймворки, наподобие jQuery, имеют соответствующие функциональные возможности, которые вы, скорее всего, и предпочтете использовать.Следующий шаг — создать панель управления с минимальными функциями форматирования.

Панель управления

Начнем с простого элемента управления: кнопки «bold», которая будет изменять начертание выделенного текста на полужирное. Еще кнопка должна отображать состояние документа — если точка ввода внутри текста с полужирным начертанием, то кнопка должна быть подсвечена.Логика разделяется между двумя объектами: объектом command, который инкапсулирует реально происходящие операции над документом и запросы состояния выделения, и объект controller, который обрабатывает события кликов и синхронизирует состояние кнопки. Разделение нужно, так как различные команды должны разделять одну логику, как мы увидим далее. События вызываются в двух точках — когда пользователь нажимает кнопку на контрольной панели, controller вызывает команду, которая выполняется над документом и когда пользователь перемещает курсор в документе мы изменяем состояние кнопки на панели управления.

Command and controller implementation

Так как команда bold изначально поддерживается API, то наш объект command представляет собой всего лишь небольшой враппер.
function Command(command, editDoc) {
  this.execute = function() {
    editDoc.execCommand(command, false, null); 
  };
  this.queryState = function() {
    return editDoc.queryCommandState(command)
  };
}
Зачем нам вообще враппер? Так как мы хотим, что бы наши нестандартные команды обладали тем же интерфейсом, что и стандартные.
Наша кнопка это просто span:
<span id="boldButton">Bold</span>
span связан с объектом command посредством контроллера:
function TogglCommandController(command, elem) {	
  this.updateUI = function() {
    var state = command.queryState();
    elem.className = state?"active":"";
  }
  bindEvent(elem, "click", function(evt) { 
    command.execute(); 	
    updateToolbar();
  });
}
Из листинга выброшен код, который отвечал за сохранение фокуса окном редактирования при нажатии на кнопку на панели управления.Ниже мы вызываем функцию ToggleCommandController для синхронизации состояния кнопки и начертания текста с учетом двух их состояний. Когда происходит нажатие на кнопку, выполняется команда. Когда вызывается событие updateUI, то span получает класс «active», или теряет его, в зависимости от состояния текста. CSS свойства, определяющие внешний вид кнопки:
.toolbar span {
  border: outset;
}

.toolbar span.active {
  border: inset;
}
Компоненты связаны следующим образом:
var command = Command("Bold", editDoc);
var elem = document.getElementById(îboldButton);	
var controller = new TogglCommandController(command, elem);
updateListeners.push(controller);
Коллекция updateListeners содержит контроллеры для панели управления. Функция updateToolbar перебирает список и вызывает метод updateUI для каждого контроллера, что бы все элементы управления точно находились в актуальном состоянии. Мы прикрепляем события так, что бы updateToolbar вызывался каждый раз, когда изменяется выделение документа:
bindEvent(editDoc, "keyup", updateToolbar);
bindEvent(editDoc, "mouseup", updateToolbar);
Так же, как показано выше, updateToolbar вызывается когда выполняется команда. Почему мы обновляем всю панель управления после выполнения каждой команды, вместо того что бы обновлять только ту кнопку, которая связана с командой? Так как состояния других элементов управления в результате выполнения команды тоже могут измениться. Например если мы применим команду выравнивания по правому краю, то изменится так же состояние кнопки выравнивания по левому краю и кнопки центрирования. Вместо отслеживания всех возможных зависимостей проще обновить всю панель управления.Теперь у нас есть базовый интерфейс для команд с двумя состояниями. С использованием получившегося фреймворка реализованы команды Bold, Italic, JustifyLeft, JustifyRight, и JustifyCenter.

Ссылка

После того, как мы реализовали основные команды текстового форматирования, я решил дать пользователям возможность добавления в документ ссылок. Управление ссылками требует более сложной логики, так как createLink не работает так, как нам бы того хотелось. Она создает ссылку, но не возвращает информации о том находится ли выделение внутри ссылки ли нет. А нам это необходимо для синхронизации состояния панели управления и выделения.Как мы можем проверить находится ли выделение внутри ссылки? Мы сделаем это написав функцию getContaining, которая поднимается выше по дереву DOM от элемента, в котором расположен курсор, до момента, когда мы найдем родитель требуемого типа (Ссылки в данном случае. Функция ничего не возвращает, если искомый элемент не найден). Если выделение находится внутри тега a, то мы внутри ссылки.Так же нам нужен способ запросить у пользователя URL для ссылки. Более крутой редактор создал бы нестандартный диалог для этого запроса, но, что бы упростить задачу, мы просто используем стандартную функцию window.prompt. Если выделение внутри ссылки мы покажем ее текущий URL, что бы пользователь мог его изменить. В противном случае показываем просто префикс http://.Код функции Linkcommand:
function LinkCommand(editDoc) {
	var tagFilter = function(elem){ return elem.tagName=="A"; }; //(1)
	this.execute = function() {
		var a = getContaining(editWindow, tagFilter); //(2)
		var initialUrl = a ? a.href : "http://"; //(3)
		var url = window.prompt("Enter an URL:", initialUrl);
		if (url===null) return; //(4)
		if (url==="") {
			editDoc.execCommand("unlink", false, null); //(5)
		} else {
			editDoc.execCommand("createLink", false, url); //(6)
		}
	};
	this.queryState = function() {
		return !!getContaining(editWindow, tagFilter);  //(7)
	};		
}
Логика работы функции следующая:
  1. Функция проверяет является ли текущий элемент искомым. tagName всегда возвращается в верхнем регистре, вне зависимости от регистра в коде.
  2. getContaining ищет элемент с заданным именем содержащий данный. Если он не найден — возвращает null.
  3. Если среди родительских элементов найдена ссылка, мы добавляем атрибут href в диалог. В противном случае в нем будет стандартное http://.
  4. prompt возвращает null, если пользователь нажимает Cancel. В этом случае выполнение команды прекращается.
  5. Если пользователь удаляет URL и нажимает OK, то мы предполагаем, что пользователь хочет удалить ссылку. Для этого мы используем стандартную команду unlink.
  6. Если пользователь вводит URL и нажимает OK, то мы создаем ссылку с помощью команды createLink. (если ссылка уже существует то мы заменяем URL на новый).
  7. Двойное отрицание дает в результате булев тип — true если элемент найден и false в противном случае.
  8. Мы можем комбинировать LinkCommand со стандартным ToggleCommandController, так как интерфейс панели управления остается неизменен: все те же методы execute и queryState.

GetContaining

Давайте рассмотрим функцию getContaining (находится в editlib.js). Функция проверяет находится ли выделение внутри элемента определенного типа.Тут все несколько сложнее, так как API IE работает несколько иначе, чем API других браузеров. Поэтому нам придется создать две независимых реализации функции и механизм, который определит, какую из них надо использовать — мы сделаем это определяя наличие свойства getSelection. Вот так вот:
var getContaining = (window.getSelection)?w3_getContaining:ie_getContaining;
Реализация функции в IE интереснее, так как она показывает некоторые особенности работы selection API в IE.
function ie_getContaining(editWindow, filter) {
	var selection = editWindow.document.selection;
	if (selection.type=="Control") { //(1)
		// control selection
		var range = selection.createRange();
		if (range.length==1) { 
			var elem = range.item(0); //(3)
		}
		else { 
			// multiple control selection 
			return null; //(2)
		}
	} else {
		var range = selection.createRange(); //(4)
		var elem = range.parentElement();
	}
	return getAncestor(elem, filter);		
}
Работает это следующим образом:
  1. Тип объекта selection либо «Control» либо «Text». Объектов (Control) может быть выделено несколько (то есть, пользователь может выделить несколько не смежных изображений с помощью ctrl+клик).
  2. Мы не будем обрабатывать ситуацию с несколькими выделенными объектами; В этом случае мы просто отменяем команду и ничего не происходит.
  3. Если у нас один объект в выделении мы выбираем его.
  4. Если выделение текстовое, мы используем это для получения контейнера.
API, используемое другими браузерами сравнительно простое:
function w3_getContaining(editWindow, filter) {
	var range = editWindow.getSelection().getRangeAt(0); //(1)
	var container = range.commonAncestorContainer;	//(2)
	return getAncestor(container, filter);	
}
Работает это следующим образом:
  1. В то время как API позволяет множественное выделение, пользовательский интерфейс позволяет только одно, так что мы рассматриваем только первый и единственный диапазон.
  2. Этот метод получает элемент, который содержит текущее выделение.
Функция getAncestor простая — Мы просто поднимаемся по иерархии элементов, пока не найдем, что ищем или пока не достигнем вершины иерархии, в этом случае мы возвращаем null:
/* walks up the hierachy until an element with the tagName if found.
Returns null if no element is found before BODY */
function getAncestor(elem, filter) {
	while (elem.tagName!="BODY") {
		if (filter(elem)) return elem;
		elem = elem.parentNode;
	}
	return null;
}

Команды принимающие множество значений

Такие элементы редактирования как выбор шрифта и кегля требуют несколько иного подхода, так как пользователь может выбрать несколько вариантов значений. В интерфейсе для реализации подобного мы использовали выпадающий список вместо кнопки, как раньше. Кроме того нам понадобятся переписать объекты Command и Controller, что бы они могли работать с множеством значений, а не только бинарными состояниями.Вот HTML код для выбора шрифта:
<select id="fontSelector">
  <option value="">Default</option>
  <option value="Courier">Courier</option>
  <option value="Verdana">Verdana</option>
  <option value="Georgia">Georgia</option>
</select>
Объект command по прежнему прост, так как является надстройкой стандартной команды FontName:
function ValueCommand(command, editDoc) {
  this.execute = function(value) {
    editDoc.execCommand(command, false, value); 
  };
  this.queryValue = function() {
    return editDoc.queryCommandValue(command)
  };
}
Разница между ValueCommand и ранее описанными командами с бинарными состояниями в наличии метода queryValue, который возвращает текущее значение в виде строки. controller выполняет команду, когда пользователь выбирает значение в выпадающем списке.
function ValueSelectorController(command, elem) {
  this.updateUI = function() {
    var value = command.queryValue();
    elem.value = value;
  }
  bindEvent(elem, "change", function(evt) { 
    editWindow.focus();
    command.execute(elem.value);	
    updateToolbar();
  });
}
Контроллер довольно прост, так как мы используем значения в выпадающем списке непосредственно как значения для команды.Выпадающий список размеров шрифта работает таким же образом — мы используем встроенную команду FontSize и используем размеры от 1 до 7 в качестве доступных значений.

Нестандартные команды

До текущего момента все изменения в HTML мы делали с помощью стандартных, встроенных команд. Но иногда может понадобится изменить HTML так, как это не может сделать ни одна встроенная команда. В этом случае мы используем DOM и Range API.В качестве примера, мы создадим команду, которая будет добавлять некий HTML в точку ввода. Что бы не усложнять, это будет просто span с текстом “Hello World”. Но подход не изменится, если вы захотите ввести вставить любой другой HTML.Команда будет выглядеть следующим образом:
function HelloWorldCommand() {
  this.execute = function() {		
    var elem = editWindow.document.createElement("SPAN");
    elem.style.backgroundColor = "red";
    elem.innerHTML = "Hello world!";
    overwriteWithNode(elem);
  }	
  this.queryState = function() {
    return false;
  }
}
Фишка в функции overwriteWithNode, которая вставляет элемент в текущую точку ввода. (Имя метода показывает, что если есть не пустое выделение, то его содержимое будет перезаписано). Из-за отличий DOM между IE и браузерами поддерживающими стандарт DOM Range метод применяется по разному.Давайте сначала рассмотрим версию работающую с DOM Range:
function w3_overwriteWithNode(node) {
  var rng = editWindow.getSelection().getRangeAt(0);
  rng.deleteContents();
  if (isTextNode(rng.startContainer)) {
    var refNode = rightPart(rng.startContainer, rng.startOffset)
    refNode.parentNode.insertBefore(node, refNode);
  } else {
    var refNode = rng.startContainer.childNodes[rng.startOffset];
    rng.startContainer.insertBefore(node, refNode);
  }
}
range.deleteContents, сообразно своему названию, удаляет содержимое выделения, если оно не вырожденное. (Если выделение вырожденное то она просто ничего не делает). У объекта DOM Range есть свойства, которые позволяют нам определить точку ввода в DOM: startContainer это узел, который содержит точку ввода и startOffset это число, которое означает позицию точки ввода в родительском узле. Например, если startContainer это элемент и startOffset равно трем, то точка ввода находится между третьим и четвертым потомком элемента. Если startContainer это текстовый узел, то startOffset означает смещение в символах от начала родителя. Например, startOffset равное 3 означает, что точка ввода между третьим и четвертым символом.

endContainer и endOffset таким же образом обозначают окончание выделения. Если выделение пустое (вырожденное), то у них то же значение что и у startContainer и startOffset.


Если точка ввода внутри текстового узла, то нам стоит разбить его на два, что бы можно было вставить между ними наши данные. rightPart это функция, которая именно это и делает — разбивает текстовый узел на два узла возвращает правую его часть. Затем мы можем использовать insertBefore что бы вставить новые узлы нужную точку.Версия для IE несколько хитрее. В IE объект Range не дает доступ к информации о положении точки ввода в DOM. Еще одна проблема — мы можем вставлять данные только с помощью метода pasteHTML, который принимает в качестве аргумента HTML в виде строки, а не дерева DOM узлов. В общем то в IE Range API полностью изолирован от DOM API! Но есть фокус, который позволит все же совместно использовать DOM API и IE Range API: Мы используем pasteHTML что бы вставить элемент-маркер с уникальным ID, что бы найти нужную точку ввода в DOM:
function ie_overwriteWithNode(node) {
  var range = editWindow.document.selection.createRange();
  var marker = writeMarkerNode(range);
  marker.appendChild(node);
  marker.removeNode(); // removes node but not children
}

// writes a marker node on a range and returns the node.
function writeMarkerNode(range) {
  var id = editWindow.document.uniqueID;
  var html = "<span id='" + id + "'></span>";
  range.pasteHTML(html);
  var node = editWindow.document.getElementById(id);
  return node;
}
Обратите внимания, что бы удаляем узел-маркер, после того как закончили. Это нужно, что бы не засорять HTML код.Теперь у нас есть команда, которая вставляет произвольный HTML в точку выделения. Мы использовали кнопку на панели управления и функцию ToggleCommandController для связи этого действия с пользовательским интерфейсом.

Выводы

В этой статье мы рассмотрели простой фреймворк для создания HTML редактора. Код можно использовать как заготовку для разработки более сложных редакторов.
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 18

    0
    Неплохо в качестве начального материала.
    Недостаток логики ссылок в вашем случае — если ссылка внутри выделения или пересекается с ним одним краем, команда не видит ссылку
      0
      Автор явно пытался сделать максимально простой пример.
      Строго говоря, подход использующий встроенные команды вообще не жизнеспособен.
      По крайней мере если не хочешь писаться и какаться от ужаса видя созданный редактором код.
      Не говоря уже про его единообразие для разных браузеров.
      –2
      Всё это здорово, но размер простейшего скрипта с костылями превышает 3к. При том что обычные полнофункциональные javascript wysiwyg редакторы имеют размер порядка 15к и при этом гарантированно работают во всех браузерах. В-общем, интересно, но на далекое будущее.
        0
        Эм… либо я смотрю не на те редакторы… либо одно из двух.
        tinyMCE и CKEditor далеко за мегабайт кода вылезли.
          0
          Я про размер скачиваемого пожатого .js
            0
            Несколько отвлекаясь от темы:
            Даже, когда нужны только 2-3 команды в редакторе — все равно приходится подключать его полностью вместе с килобайтами ненужного в конкретном случае функционала.
            Неплохим решением мне видится кастомная сборка редактора, наподобие сборки jquery ui
              0
              Обычно так и делают.
              Не стоит это рассматривать, как предлагает автор, как заготовку для редактора.
              С момента написания статьи 3 года прошло все же.
              Но это должно помочь понять принципы работы редакторов и как в них вообще что работать может.
          0
          все хорошо, только подсветка кода — говно. какой гений додумался оставить в коде sans-serif шрифт? используйте встроенную подсветку в хабр. она неплоха)
            0
            Спасибо за совет. В следующий раз так и сделаю.
              0
              вы, впринципе, можете и сейчас поправить, чтобы читать код приятнее было ;)
                +1
                Сделал.
            0
            FF2 мягко говоря не актуален. Так что, думаю, iframe никому больше не нужен.


            Зря Вы так думаете. Сертифицированный в РФ для обработки секретной информации (на прошлую весну, но уверен, что ничего не сдвинулось) КГОД 2 есть даже не Fx2, а Fx1.5.
              +4
              Не думаю, что стоит принимать во внимание настолько специфические ситуации.
              99% разработчиков никогда не столкнуться с КГОД 2, а 90% не знают что это.
              Да, цифры, возможно, утрированы и взяты с потолка, но, думаю, я не слишком сильно ошибаюсь.

              Для FF2 всегда остаются старые редакторы.
              Но разрабатывать с учетом FF2 что то новое — довольно странная затея, если у вас нет явных причин разрабатывать что то именно под него.
                0
                Ситуация специфическая, но это всё же не «никому больше не нужен» и даже не затея какой-нибудь одинокой банды фриков в глухой провинции, а всё ж таки предмет госзаказов.
                  0
                  Ну… нетшкаф и мозаик тоже, очевидно, кому то нужны.
                  Но процент заказов под них такой, что о нем даже говорить не стоит.
                  Повторяю еще раз — в общем случае, без присутствия его, как необходимого, в Т.З. и дополнительной оплаты специфики заказа не имеет никакого смысла поддерживать FF 2.

                  Кстати, по моему опыту работы с гос-структурами… это именно банда фриков из глухой провинции.
              0
              Ссылка на Часть2 ведет на Часть1.
                0
                Поправил
                0
                Потрясающе. а где можно посмотреть работающую версию?

                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                Самое читаемое