Xpoint
   [напомнить пароль]

Пишем правильный online WYSIWYG-редактор

Оглавление

Введение и понимание сути проблемы

Зачем это нужно

WYSIWYG (What You See Is What You Get) — это среда, в которой пользователь сразу видит результат своей работы. К примеру, редактор Frontpage — мы сразу видим документ в практически окончательном виде, в отличие от работы с исходным кодом страницы. Сделать редактор онлайновым позволяет поддержка браузерами Microsoft Internet Explorer (версии 5.5 и выше), Mozilla (1.3+)/ Firefox и Opera (9.0+) режима WYSIWYG-редактирования текста (designMode).

Примечание 1: Mozilla и Firefox далее будем объединять именем Gecko (это название их движка).

Примечание 2: Пока Opera 9 не является релизом, поэтому про нее потом напишем. Вообще, реализация designMode в Opera похожа на реализацию такового в Gecko.

WYSIWYG-редактор позволяет значительно облегчить рутинную работу публикации контента. Также он снижает требования к квалификации работающего с контентом персонала. Так что штука это полезная и нужная.

Но есть у него и серьезный недостаток — назовем его "синдромом Ворда" — преобладание визуальности разметки над логикой, когда, условно говоря, заголовок в документе делается путем выставления большого и жирного шрифта. В нашем проекте мы попытаемся избежать этого недостатка (или по крайней мере минимизировать его).

Стоит разграничить верстальщика, который делает шаблоны, сложные конструкции, и оператора, который работает с контентом. Будем считать, что онлайн-редактор стоит делать ориентируясь главным образом именно на оператора, верстальщик пускай верстает в блокноте или специализированном редакторе. Он профессионал, ему виднее. А вот для оператора дополнительные редакторы — лишнее. Поэтому пусть он работает в административном интерфейсе с нашим WYSIWYG-редактором, одной из задач которого будет не выпускать оператора за пределы заданной дизайнером таблицы стилей (и облегчать ему "возвращение на путь истинный"). Не должно быть возможности (или она должна быть затруднена) повесить на сайт "жирный красный комик +3" в обход дизайнера.

Каким это должно быть (Правильный WYSIWYG)

Очевидно, что подавляющее большинство предлагающихся online-редакторов построены по принципу "Выберите шрифт, размер, цвет", т. е. к правильным не относятся. Ибо разметку кода, полученного с их помощью, логической назвать никоим образом нельзя. Значит, мы будем писать редактор, где пользователь оперирует сущностями типа "Заголовок N-го уровня", "Абзац-примечание" и "Важный текст", а как они выглядят — решает дизайнер сайта, прописав свое решение в таблице стилей. Поэтому наш редактор должен будет оперировать разрешенными тегами и стилевыми классами.

Воплощение

Большой проблемой, стоящей перед нами, является то, что стандартные интерфейсы к встроенному редактору, реализованные в браузерах, как раз имеют большой уклон в сторону "шрифта-размера-цвета". Этим, кстати, и обусловлена "неправильность" вышеупомянутых WYSIWYG-редакторов. В интерфейсе есть команда "покрасить шрифт", есть команда "выставить размер шрифта", но нет команды "обрамить выделение нужным тегом с нужным классом". Подробнее об API (Application Program Interface — интерфейс к программированию приложения) встроенных редакторов см. информацию на сайтах Microsoft Developer Network — MSDN и Mozilla.

Так что наша работа будет заключаться в реализации этих дополнительных возможностей.

Примечание: Поскольку война браузеров пока не кончилась, то мы имеем необходимость для разных браузеров писать разный код. Различать браузеры будем путем проверки, какой код они поддерживают, а какой — нет. Это позволит нам не заморачиваться проверкой userAgent'ов и версий.

Итак, сформулируем, что нам нужно:

  • Оформление выделения нужным блочным тегом (к счастью, имеется команда formatBlock) с нужным атрибутом class (а тут уже ничего готового нет) или без оного.
  • Оформление выделения нужным строчным (inline) тегом с классом или без.
  • Присвоение атрибутов (в основном классов) нетекстовым объектам — картинкам (им еще полезно присваивать src и alt), таблицам, линиям <hr>.
  • Очистка форматирования, не подходящего под заданную таблицу стилей (полезно при копировании текста с документов Microsoft Office, других web-страниц и т. д.).

Ну и плюс ко всему редактор должен оправдывать звание "WYSIWYG", учитывая при отображении текста CSS-файлы с сайта.

Панель редактирования

Кроссбраузерная панель редактирования представляет собой document, которому свойство designMode установлено в "On". Поскольку обычно нам не нужно, чтобы редактированию подвергалось все содержимое окна браузера, удобно заключать этот document во фрейм (обычный — frame или плавающий — iframe).

Хорошей идеей будет добавить к этому фрейму еще и textarea для возможности переключать редактор из визуального режима в режим работы с HTML-кодом.

Браузеры автоматически заменяют адреса вставляемых в редактор относительных ссылок, преобразуя их в абсолютный вид. То есть, условно говоря, если наш редактор имеет адрес http://www.site.ru/admin/, мы в него вставляем картинку с адресом image.gif, то она автоматически преобразуется в http://www.site.ru/admin/image.gif и картинку мы, скорее всего, не увидим. Это является проблемой, так как для "правильного" редактора очень желательно иметь возможность вставлять относительные ссылки.

Решать эту проблему будем так:

Во-первых, нужно, чтобы у документа, служащего панелью редактирования, адресом был бы адрес той страницы на сайте, которую мы редактируем с точностью до location.search (части адреса после "?"). Тогда относительные ссылки с текста в редакторе и на сайте будут одинаковы.

Во-вторых, следует при переключении редактора в режим HTML-source и при сохранении преобразовывать адреса ссылок в относительный вид (как минимум, удалять из них часть адреса, общую для редактируемой страницы и ссылки).

В связи с этим, во фрейм будем подгружать отдельный документ с нужным адресом (к примеру, пусть движок сайта выдает пустой документ при запросе страницы с ключом ?wysiwyg=yes). Вернее, не совсем пустой, для MSIE нужно, чтобы в документе был тег <body>, иначе нечему будет присваивать innerHTML. Выдачу движком не пустого HTML, а редактируемого текста считаем нецелесообразным, т. к. это излишне усложняет движок без какой бы то ни было пользы. Текст мы будем получать из textarea, все равно необходимой для интерфейса редактора.

Заодно в этот подгружаемый документ можно вписать подгрузку стилей:
<link rel="stylesheet" href="css-файл" type="text/css">
<body></body>,

Также таблицу стилей можно привязать к документу, загруженному в iframe, путем создания методами DOM в его head'е элемента типа <link>, указывающего на файл с таблицей стилей.

var style = document.createElement('link')
style.rel = 'stylesheet'
style.type = 'text/css'
style.href = 'myStyleSheet.css'
document.getElementsByTagName('head')[0].appendChild(style)

Здесь document — это документ фрейма-редактора.

С присваиванием контента могут быть некоторые проблемы, связанные с тем, что присваивание надо делать после всевозможных onload'ов и через некоторый таймаут после установки designMode (в MSIE). Можно предложить такое решение:
Через try-catch() пытаемся присвоить innerHTML, если не получается, делаем небольшой setTimeout и пробуем снова. Практика показывает, что даже при таймауте в 0 миллисекунд зацикливания не происходит. Можно и изначально делать присваивание по таймауту.

Примечание 1: Сначала мы устанавливаем designMode, потом присваиваем контент.

Примечание 2: В Gecko нельзя устанавливать designMode у скрытого элемента (display:none). Это надо будет учесть, так как делается редактор с переключающимися панелями WYSIWYG / HTML-исходник.

Начнем писать код.

HTML

<textarea style="width:100%;height:350px" id="wysiwyg_textarea"></textarea>
<iframe id="wysiwyg_iframe" style="display:none;width:100%;height:350px" src="canvas.html"></iframe>

Здесь мы имеем textarea для работы с HTML-source и iframe для WYSIWYG. Редактор находится в режиме HTML-source (iframe спрятан). Чтобы изменить умолчание, достаточно перенести display:none; в стили textarea.

Можно добавить кнопку для переключения режимов:

<button onclick="wysiwyg_switch_mode('wysiwyg_textarea', 'wysiwyg_iframe')">Переключить режим отображения</button>

Сейчас мы не задумываемся над особой функциональностью. Можно сделать checkbox, можно сделать переключающиеся вкладки "Normal – HTML" и т. д.

Javascript

// Инициализация редактора
onload = function(){
   wysiwyg_init('wysiwyg_textarea', 'wysiwyg_iframe')
}

// Функции инициализации на вход мы даем id составляющих редактор textarea и iframe
function wysiwyg_init(textarea_id, iframe_id){
   var textarea = document.getElementById(textarea_id)
   var iframe = document.getElementById(iframe_id)
   // Проверим на существование iframe и textarea
   // Через offsetWidth проверим видимость iframe – то есть редактор находится в визуальном режиме
   if(iframe && textarea && iframe.offsetWidth){
      iframe.contentWindow.document.designMode = 'On'
      // Для Gecko устанавливаем такой режим, чтобы форматирование ставилось тегами, а не стилями
      // Чтобы MSIE не выдавал ошибку, прячем это в конструкцию try-catch
      try{
         iframe.contentWindow.document.execCommand("useCSS", false, true)
      }catch(e){}

      // Копируем текст из textarea в iframe
      wysiwyg_textarea2iframe(textarea_id, iframe_id)
   }
}

// Копирование текста из textarea в iframe
function wysiwyg_textarea2iframe(textarea_id, iframe_id){
   try{
      document.getElementById(iframe_id).contentWindow.document.body.innerHTML = document.getElementById(textarea_id).value
   }catch(e){
      setTimeout("wysiwyg_textarea2iframe('" + textarea_id + "', '" + iframe_id + "')", 0)
   }
}

// Переключение редактора из визуального режима в HTML-режим и обратно
function wysiwyg_switch_mode(textarea_id, iframe_id){
   var textarea = document.getElementById(textarea_id)
   var iframe = document.getElementById(iframe_id)
   if(iframe && textarea){
      // редактор в режиме редактирования HTML-source
      if(textarea.offsetWidth){
         // Сначала показываем iframe, потом прячем textarea.
         // Такой порядок для того, чтобы прокрутка не перескакивала
         // из-за укоротившейся на миг страницы.
         iframe.style.display = ''
         textarea.style.display = 'none'
         wysiwyg_init(textarea_id, iframe_id)
         iframe.focus()
      }else{ // Редактор в визуальном режиме
         textarea.style.display = ''         
         iframe.style.display = 'none'
         textarea.value = iframe.contentWindow.document.body.innerHTML
         textarea.focus()
      }
   }
}

Выделение / Selection

"Выделение" (selection) является ключевым понятием в работе редактора. Это область, на которую будет распространяться команда форматирования. Она может быть текстовой и "объектной". Попробуйте в каком-нибудь редакторе (например, Word) сделать документ с картинкой, потом ткнуть мышкой в картинку и нажать Ctrl+A (выделить все) — вы увидите, что выделение картинки будет выглядеть по-разному — в первом случае она выделена как картинка (объект), во втором — как часть текста.

Если мы захотим у изображения <img> указать класс, мы должны будем выделить его объектно. Если мы хотим поставить с него ссылку — текстово.

Наш редактор должен уметь получать список выделенных узлов документа, при необходимости создавая новые (если выделена часть узла, к которому надо применить inline-форматирование).

Базовой функцией работы с выделением является получение начального и конечного узлов выделения. Из этой пары мы сможем получить весь набор входящих в выделение узлов нужных нам типов.

Получаем начальный и конечный узлы выделения (а так же их ближайшего общего родителя)

Примечание: Здесь есть нетривиальность, связанная со "странной" реализацией выделения в MSIE.

// Взятие крайних узлов выделения (корня — root и самых крайних "слева" и "справа" — start и end)
// на вход даем окно (т.е. iframe.contentWindow)
function get_selection_bounds(editor_window){
   var range, root, start, end

   if(editor_window.getSelection){ // Gecko, Opera
      var selection = editor_window.getSelection()
      // Выделение, вообще говоря, может состоять из нескольких областей.
      // Но при написании редактора нас это не должно заботить, берем 0-ую:
      range = selection.getRangeAt(0)
      
      start = range.startContainer
      end = range.endContainer
      root = range.commonAncestorContainer

      if(start.nodeName.toLowerCase() == "body") return null
      // если узлы текстовые, берем их родителей
      if(start.nodeName == "#text") start = start.parentNode
      if(end.nodeName == "#text") end = end.parentNode

      if(start == end) root = start
      
      return {
         root: root,
         start: start,
         end: end
      }

   }else if(editor_window.document.selection){ // MSIE
      range = editor_window.document.selection.createRange()
      if(!range.duplicate) return null
      
      var r1 = range.duplicate()
      var r2 = range.duplicate()
      r1.collapse(true)
      r2.moveToElementText(r1.parentElement())
      r2.setEndPoint("EndToStart", r1)
      start = r1.parentElement()
      
      r1 = range.duplicate()
      r2 = range.duplicate()
      r2.collapse(false)
      r1.moveToElementText(r2.parentElement())
      r1.setEndPoint("StartToEnd", r2)
      end = r2.parentElement()
      
      root = range.parentElement()
      if(start == end) root = start
      
      return {
         root: root,
         start: start,
         end: end
      }
   }
   return null // браузер, не поддерживающий работу с выделением
}

Получаем список всех узлов с определенным именем тега, лежащих между началом и концом выделения

Когда мы применяем к блокам или инлайнам команду форматирования определенным тегом с определенным классом, у нас может получиться много узлов, поэтому нам интересны не только начальный и конечный. Банальный проход по nextSibling'ам не подходит, т.к. нам пожет понадобится в процессе обхода подниматься и опускаться по дереву узлов. Посему алгоритм такой - получив ближайшего общего родителя (root), из поддерева его потомков, ограниченного начальным и конечным узлами, выбираем все нужные нам узлы.

функция, скорее всего, нуждается в оптимизации, а то она через глобальную переменную написана...

var global_stage // некрасивая глобальная переменная
// bounds — массив [root, start, end]
// tag_name — имя тега
// остальные аргументы не указываем, используются для рекурсии
function find_tags_in_subtree(bounds, tag_name, stage, second){
   var root = bounds['root']
   var start = bounds['start']
   var end = bounds['end']

   if(start == end) return [start]

   if(!second) global_stage=stage

   if(global_stage == 2) return []
   if(!global_stage) global_stage = 0

   tag_name = tag_name.toLowerCase()

   var nodes=[]
   for(var node = root.firstChild; node; node = node.nextSibling){
      if(node==start && global_stage==0){
         global_stage = 1
      }
      if(node.nodeName.toLowerCase() == tag_name && node.nodeName != '#text' || tag_name == ''){
         if(global_stage == 1){
            nodes.push(node)
         }
      }
      if(node==end && global_stage==1){
         global_stage = 2
      }
      nodes=nodes.concat(find_tags_in_subtree({root:node, start:start, end:end}, tag_name, global_stage, true))
   }
   return nodes
}

Ближайший родитель с нужным тегом

На вход даем узел и имя тега. Если узел уже является нужным тегом, если у него нет подходящих родителей или если имя тега пусто, возвращаем этот узел. Иначе возвращаем ближайшего родителя, у которого нужное имя тега.

// Ближайший родитель с нужным тегом
function closest_parent_by_tag_name(node, tag_name){
   tag_name = tag_name.toLowerCase()
   var p = node
   do{
      if(tag_name == '' || p.nodeName.toLowerCase() == tag_name) return p
   }while(p = p.parentNode)

   return node
}

Массив всех узлов с нужным тегом, попавших в выделение

function get_selected_tags(editor_window, tag_name){
   if(tag_name){
      tag_name = tag_name.toLowerCase()
   }else{
      tag_name = ''
   }
   var bounds = get_selection_bounds(editor_window)
   if(!bounds) return null

   bounds['start'] = closest_parent_by_tag_name(bounds['start'], tag_name)
   bounds['end'] = closest_parent_by_tag_name(bounds['end'], tag_name)
   return find_tags_in_subtree(bounds, tag_name)
}

Форматирование блоков

В API есть команда formatBlock, ей на вход дается имя блочного тега и она оформляет текущее выделение этим тегом.
Например:
document.execCommand("formatBlock", false, "<h1>").

Далее все просто — мы применим эту команду и потом выберем из выделения все теги нужного имени (tagName), которым и присвоим нужный className:

// Оформляем выделение нужным блочным тегом с нужным классом
function wysiwyg_format_block(iframe_id, tag_name, class_name){
   var iframe = document.getElementById(iframe_id)
   var wysiwyg = iframe.contentWindow.document
   // Оформляем нужным блочным тегом
   wysiwyg.execCommand('formatblock', false, '<' + tag_name + '>')
   // Выбираем из выделения все теги нужного имени и ставим им класс
   var nodes = get_selected_tags(iframe.contentWindow, tag_name)
   for(var i = 0; i < nodes.length; i++){
      if(class_name){
         // Устанавливаем класс
         nodes[i].className = class_name
      }else{
         // Убираем класс, если он нам не нужен
         nodes[i].removeAttribute('class')
         nodes[i].removeAttribute('className')
      }
   }
   iframe.focus()
}

Форматирование слов (inline)

На первый взгляд может показаться, что между инлайновыми и блочными элементами какой-то принципиальной разницы нет, что их можно обрабатывать аналогичным образом. На самом деле это не так... Дело в том, что в API визуального редактора нет команды для оформления произвольным строчным тегом. А нам нужно как-то вставить несуществующий пока тег по границам выделения. Есть идея использовать для этого какую-то из команд, вставляющих идеологически вредный, но строковый тег.

Перспективным решением выглядит использование команды ForeColor, которая вставляет <font color=...>. Сам по себе <font> нам в нашем идеологически выдержанном редакторе не нужен абсолютно, но это позволит нам штатным образом создать строковые узлы, которые мы опять же сможем выбрать из выделения и поменять им tagName и className (и убрать атрибут color). Для надежности можно вставлять и потом искать какой-то конкретный цвет, подобранный таким образом, чтобы практически исключить ситуацию, когда он попадется нам в скопированном с другого документа тексте, например, #00ff01 (хотя его все равно бы уничтожил задуманный нами очиститель HTML).

// "Магический" неиспользуемый цвет
var magic_unusual_color='#00f001'
// Оформляем выделение нужным строковым (инлайновым) тегом с нужным классом
function format_inline(iframe_id, tag_name, class_name){
   var iframe = document.getElementById(iframe_id)
   var wysiwyg = iframe.contentWindow.document
   // Убираем все существующее форматирование
   wysiwyg.execCommand('RemoveFormat', false, true)
   // В MSIE после RemoveFormat остаются span-ы, удалим их тоже
   clean_nodes(get_selected_tags(iframe.contentWindow, 'span'))

   // Если имя тега не указано (применяется, когда мы хотим просто убрать форматирование)
   if(tag_name!=''){
      // Вставляем наш <font color>
      wysiwyg.execCommand('ForeColor', false, magic_unusual_color)

      // Заменяем узлы, образованные font'ами, на новые с нужным именем и классом
      var nodes=get_selected_tags(iframe.contentWindow, 'font')
      var new_node
      for(var i=0;i<nodes.length;i++){
         if(nodes[i].getAttribute('color') != magic_unusual_color) continue
         new_node = wysiwyg.createElement(tag_name)
         if(class_name) new_node.className = class_name
         new_node.innerHTML = nodes[i].innerHTML
         nodes[i].parentNode.replaceChild(new_node, nodes[i])
      }
   }
   iframe.focus()
}

// Чистка узлов (удаляем тег, оставляем содержимое)
// (Только для MSIE)
function clean_nodes(nodes, class_name){
   if(!nodes) return
   var l = nodes.length - 1
   for(var i = l ; i >= 0 ; i--){
      if(!classname || nodes[i].className == class_name){
         nodes[i].removeNode(false)
      }
   }
}

Себе: закроссбраузерить clean_nodes

BUGS:
1: MSIE:пропадают граничные пробелы (попавшие в выделение и крайние в нем. По-видимому, из-за переприсвоения innerHTML)
2: иногда в MSIE при применении инлайн-форматирования на несколько абзацев сразу часть текста остается зеленой (magic_unusual_color). В мозиле, кстати, тоже иногда, но при других обстоятельствах... Может, выбирать все fontы из всего документа, а не только из выделения? // Круглов

Работа со списками

Имеется в виду работа с нумерованными и маркированными списками. На наше счастье в API уже почти есть (и даже больше чем нужно, но не будем забегать вперед).

Мы хотим преобразовывать абзацы в оба вида списков и обратно, а также управлять вложенностью списков (менять отступы).

Вот список имеющихся в designMode API команд:

  • InsertOrderedList — вставить <ol>
  • InsertUnorderedList — вставить <ul>
  • Indent — увеличить отступ (сделать подсписок)
  • Outdent — уменьшить отступ (выйти из подсписка)

Со сменой отступов есть небольшая проблемка — если мы увеличиваем отступ не у списка, вставляется тег <blockquote>. Он нам тут совершенно не нужен. Однако мы, вооружась недавно описанной функцией clean_nodes, его незамедлительно удалим.

// Работа со списками. Передаем на вход одну из команд:
// ul, ol, indent, outdent
function list(iframe_id, command){
   var iframe = document.getElementById(iframe_id)
   var wysiwyg = iframe.contentWindow.document
   switch(command){
      case 'ol':
         wysiwyg.execCommand('InsertOrderedList')
         break
      case 'ul':
         wysiwyg.execCommand('InsertUnorderedList')
         break
      case 'indent':
         wysiwyg.execCommand('Indent')
         // удаляем <BLOCKQUOTE>
         clean_nodes(get_selected_tags(iframe.contentWindow, 'blockquote'))
         break
      case 'outdent':
         wysiwyg.execCommand('Outdent')
         break
      
   }
   iframe.focus()
}

Чистка кода (-)

Литература

Powered by POEM™ Engine Copyright © 2002-2005