Оглавление

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

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

Воплощение

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

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

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

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

  • Оформление выделения нужным блочным тегом (к счастью, имеется команда formatBlock) с нужным атрибутом class (а тут уже ничего готового нет) или без оного.
  • Оформление выделения нужным строчным (inline) тегом с классом или без.
  • Нахождение списка допустимых классов для данного объекта (чтобы в полной мере реализовать "каскадность" таблиц стилей).
  • Присвоение атрибутов (в основном классов) нетекстовым объектам — картинкам (им еще полезно присваивать src и alt), таблицам, линиям <hr>.
  • Очистка форматирования, не подходящего под заданную таблицу стилей (полезно при копировании текста с документов Microsoft Office, других web-страниц и т. д.).
  • Формирование структуры абзацев (это касается прежде всего Midas и Opera). Текст обычно состоит из абзацев. Однако, нажатие "enter" нередко просто вставляет <br> (при нажатом shift всегда вставляется <br> так что пусть shift+enter будет способом вставить перевод строки, а не начать новый абзац), причём если курсор стоит в конце абзаца/заголовка/чего-то-ещё, то вставляется даже сразу 2 (sic!) <br>. При склейке абзацев также иногда появляется ненужный <br>.

Ну и плюс ко всему редактор должен оправдывать звание "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 == end) root = start

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

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 // браузер, не поддерживающий работу с выделением
}

Данный код не работает так как надо, потому что root  - здесь точно тот родитель, который будет общим для выделенных элементов. Однако Midas работает иначе: пусть у нас есть код <ul><li><i>xxx</i></li></ul> и в нём выделен весь текст "xxx". Поскольку ничего кроме этого текста нет в <i>, а <i> -  единственное содержимое  единственного элемента списка, то при форматировании цветом получим <ul style="color:#909090"><li><i>xxx</i></li></ul>, то есть по идее root должен быть ul, а не i, по крайней мере, такой root был бы нам полезен.

в IE иногда root = range.parentElement() не срабатывает и выдаёт неверный результат. Пришлось писать свой алгоритм поиска общего родителя.

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

Когда мы применяем к блокам или инлайнам команду форматирования определенным тегом с определенным классом, у нас может получиться много узлов, поэтому нам интересны не только начальный и конечный. Банальный проход по 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>").

Данная команда поддерживает только некоторые тэги, например, h1...h6 и pre.
А вот тэг blockquote она не поддерживает. Mozilla при использовании неподдерживаемого тэга добавляет к нему слева и справа угловые скобки (и из <blockquote> получается <<blockquote>>), что позволяет исполнить следующий хак: подать неподдерживаемый параметр без угловых скобок и вставить таким образом неподдерживаемый тэг. Однако, ни Opera ни IE не обладают такой особенностью, поэтому данный хак практической пользы не имеет.
Кроме того, Mozilla не поддерживает div, так что польза от команды вообще под вопросом. <blockquote>, конечно же, можно создать и при помощью команды Indent, но у неё есть "побочный эффект" - она влияет на списки.

Эта команда обладает следующей особенностью: в выделении уничножаются аналогичные (то есть все поддерживаемые командой) тэги. Соответственно, становится невозможно сначала набрать текст (с абзацами, заголовками и цитатами), а потом, ну скажем, оформить его в рамку (<div class="framed"></div> - и это при том, что div поддерживается только в IE) - все заголовки и абзацы превратятся в div'ы.

Далее все просто — мы применим эту команду и потом выберем из выделения все теги нужного имени (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()
}

Гарантированно работает некорректно при обработке фрагмента, где уже есть такой же тэг но с другим классом - старому тэгу будет присвоен класс нового (маловероятно, т.к. обычно FormatBlock "съедает" тэги, но тэг может быть и не в выделении а рядом - у того же родителя). Обработать можно так: при присвоении класса устанавливать ещё и какое-нибудь флаговое поле, отмечающее, что этот тэг трогать на надо, например так:

// Оформляем выделение нужным блочным тегом с нужным классом
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(
nodes[i].editorAccepted){
continue
}else{
nodes[i].editorAccepted=1
 }
if(class_name){
// Устанавливаем класс
nodes[i].className = class_name
}else{
// Убираем класс, если он нам не нужен
nodes[i].removeAttribute('class')
nodes[i].removeAttribute('className')
}
}
iframe.focus()
}

По причинам описанным выше (отсутствие полной поддержки <div>, blockquote) мной был реализован алгоритм,
который обрамляет выделения тэгами <div> и <blockquote>, а данный алгоритм использован для заголовков и <pre>.

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

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

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

// "Магический" неиспользуемый цвет
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
тааак... приплыли...
wysiwyg.execCommand('RemoveFormat', false, true) очистит например такие структурные тэги как <em> и <strong>. и сделать выделенное слово в усиленном предложении уже не получится.
Как я понимаю,  вся чистка требуется из-за того, что возможно вложения тэга в него самого (и не раз и не два): браузер следит, чтобы одноимённые тэги не вкладывались друг в друга, но мы используем <font>, а его-то заведомо нет.

Чистку имеет смысл делать вручную: хранить массив/хэш уже имеющихся выше текущего тегов и удалять аналогичные, например, тэг <strong> внутри тэга <strong>. Исключением могут оказаться тэги <span>, которые с (конечно, с разными классами) можно вкладывать друг в друга. Однако, делая редактор прежде всего для правки wiki-подобного форматирования, я отказался от такой возможности.

Впрочем, сопоставимой по сложности задачей будет и прямая вставка тэгов через DOM-дерево, без форматирования волшебным цветом.
К сожалению, замена тэгов (как, кстати и вообще почти любая работа с DOM, особенно в районе выделения) очень нехорошо влияет на выделение
и на историю отмены. При замене тэгов выделение ведёт себя практически непредсказуемо (особенно при "чистке" результата) поэтому его стоит принудительно сбросить (что, кстати, затруднит сделать "выделенный усиленный текст со стилем" - наш аналог "красного комика") или вернуть к первоначальным границам. История отмены становится в нашем случае непригодной для использования и её имеет смысл заблокировать: при нажатии на ctrl+z выполнять команду Redo (а потом, возможно, и наш собственный вызов нашей реализации истории команд).

Частным случаем форматирования является операция "выбрать формат", при которой __сначала__ выбирается, как писать (ну например, подчёркнутым), а потом уже пишется. ОБЯЗАТЕЛЬНО нужно заблокировать вызов команды форматирования при пустом выделении, так как в таком случае ничего не вставится (а значит и не будет заменено), а при нажатии последующих кнопок - вставится, вот и появится наш "волшебный" цвет. Вполне достаточным будет запомнить, что именно было выбрано, выполнить wysiwyg.execCommand('ForeColor', false, magic_unusual_color) и при последующем нажатии символа (а не стрелок, например), если выделение (оно пустое и на том же месте - просто позиция курсора) не изменилось, то только тогда делать нашу замену с чисткой и возвращать курсор на место, если он вдруг уползёт. Другим вариантом будет создать новый (сразу нужный) тэг в позиции курсора, занять его пробелом и выделить этот пробел. Потом при нажатии символа почистить наш "волшебный" цвет и установить курсор сразу после символа (он заменит пробел), убрать "волшебный цвет" (он может и появиться), а если будет нажато что-то другое, то убрать этот тэг вместе с пробелом как непригодившийся.


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

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

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

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

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

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

А что же про списки определений? <dl><dt>термин</dt><dd>определение термина</dd></dl>?
Со сменой отступов есть небольшая проблемка — если мы увеличиваем отступ не у списка, вставляется тег <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()
}

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

BUGS (ага, так вы от меня и отвязались):
1: В Mozilla легко сделать пустой тэг, например <p> - курсор в него не попадает, удалить его можно лишь вместе с обрамляющими тэгами.
2: В Mozilla можно удалить <body>, после чего с документом уже чего-то сложно сделать...
3: Списки  вставляются не по стандарту - <ul><li>...</li><ol><li>...</li></ol></ul> а не
<ul><li>...<ol><li>...</li></ol></li></ul> - нужно исправлять, причём сразу же, а не при сохранении - если в css определено li ul{...}, то оно не подходит под порождаемый html
4: Ctrl+Z не работает как нужно (из-за манипуляций с DOM), курсор скачет.


Литература

Комментарии

2005-05-01 19:22:29 [обр] Старынин Валерий[досье]
А как быть с Opera? Есть под нее что-нибудь, WYSIWYGоподобное?
спустя 1 час 48 минут [обр] Владимир Палант[досье]
Нет.
спустя 3 дня [обр] Александр Лукьянов[досье]

Подключение таблицы стилей — а почему бы не сделать так:

<html>
<head>
<title>designMode test</title>
<script type="text/javascript">
function editor(iframe){
iframe.contentWindow.document.designMode='On';
iframe.contentWindow.document.open();
iframe.contentWindow.document.write('<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">\n<html xmlns="http://www.w3.org/1999/xhtml" lang="ru" xml:lang="ru">\n<head>\n<title>Редактирование</title>\n<link rel="stylesheet" type="text/css" href="css.css">\n</head>\n<body><p>Параграф</p><p>Параграф</p><p>Параграф</p>\n</body></html>');
iframe.contentWindow.document.close();
}
</script>
</head>
<body onLoad="editor(document.getElementById('myeditor'))">
<iframe src="about:blank" id="myeditor" style="height: 400px; width: 500px"></iframe>
</body>
</html>

css.css
=======
body {
border-right: 30px solid #060;
}
p {
border-left: 2px solid #c00;
margin-left: 4em;
padding-left: 1em;
}

У меня прекрасно работает (IE5.5+, Firefox). Может я какие-то концептуальные изъяны упустил из виду? document.write() — штука, конечно, нестандартная, но innerHTML — тоже.

спустя 4 дня [обр] Сергей Круглов[досье]
Александр Лукьянов[досье]
да, возможно...
спустя 1 месяц 18 дней [обр] Владимир Палант[досье]
Сергей, все эти if (MSIE) и if (Gecko) режут глаза, при этом обойтись без них ведь абсолютно несложно. Вы не против, если я исправлю?
спустя 6 минут [обр] Владимир Палант[досье]
PS: Может быть в get_range_bounds() использовать nodeType вместо nodeName == '#text'? Вроде более корректно будет.
PPS: window.getSelection() возвращает в Gecko Selection-объект, а не Range. Может переменную лучше назвать иначе?
спустя 4 месяца 18 дней [обр] Владимир Палант[досье]
Хм, Range — это DOM-объект, согласен. Но TextRange — это абсолютно другой зверь, ничуть не похожий. Функция get_range пытается объединить то, что объединять нельзя...
спустя 50 минут [обр] Сергей Круглов[досье]

А как тогда?
У меня get_range дает этом самый range. Вы имеете в виду, что то, что получается для MSIE и для Gecko имеет разный физический смысл?

В приципе, этот range я планирую использовать для:
- получения начального и конечного узлов выделения.
- получения выделенного объекта (картинки, hr, возможно, таблицы)
- pasteHTMLя (вставки всяких &euro;). В Gecko - через insertNode

Я сначала сконструировал работоспособный редактор, теперь его стараюсь оптимизировать и одновременно описывать.

Да, везде, где я использую этот пресловутый "get_range", дальше код для MSIE и Mozilla отличается...

спустя 16 часов [обр] Владимир Палант[досье]

Выкинуть get_range совсем, эта функция все равно ничего не экономит. get_range_bounds переименовать в get_selection_bounds:

function get_selection_bounds(editor_window)
{
if (editor_window.getSelection)
{
var range = editor_window.getSelection().getRangeAt(0);
var start = range.startContainer;
...
}
else if (editor_window.document.selection)
{
var range = editor_window.document.selection.createRange();
var r1 = range.duplicate();
...
}
else
return null;
}

Если повезет, то это даже будет работать в Опере 9 без изменений. Других функций, использующих get_range, сейчас нет, поэтому про них ничего сказать не могу. Идеально было бы, конечно, все дальнейшие операции производить с результатом get_selection_bounds или подобной функции.

спустя 4 минуты [обр] Владимир Палант[досье]
PS: Нужно указать на то, что пометить несколько областей невозможно. В будущем это может измениться, тогда getRangeAt(0) может оказаться некорректным подходом.
неверно! Mozilla активно использует множественные выделения при работе с таблицами, например, чтобы выделить несколько ячеек, не лежащих в одной строке. IE якобы (по MSDN)  поддерживает команду MultipleSelection (Allows for the selection of more than one site selectable element at a time when the user holds down the SHIFT or CTRL keys), но мне не удалось заставить её работать.
спустя 4 часа 40 минут [обр] Сергей Круглов[досье]

Range я использую для взятия выделенного объекта (картинки)

   if(MSIE){
if(range.item){
image=range.item(0)
}
}else if(Gecko || Opera){
image=range.startContainer.childNodes[range.startOffset]
}

Для впечатки произвольного текста

   if (MSIE){
range.pasteHTML(html);
}else if(Gecko || Opera){
var fragment = wysiwyg.createDocumentFragment();
var div = wysiwyg.createElement("div");
div.innerHTML = html;
while (div.firstChild) {
fragment.appendChild(div.firstChild);
}

// this also removes the selection
range.insertNode(fragment);
}
спустя 42 минуты [обр] Сергей Круглов[досье]
Хотя да, в самом деле, экономия призрачная...
спустя 2 часа 11 минут [обр] Владимир Палант[досье]

А чем .getRangeAt(range.rangeCount - 1) лучше чем .getRangeAt(0)? :)

Проверку if(!range.duplicate) ИМХО можно выкинуть. Кроме IE никакой браузер не поддерживает TextRange, а там метод duplicate есть всегда.

У Range есть свойство commonAncestorContainer. У TextRange есть метод parentElement, у которого вроде бы тот же смысл. Может в get_selection_bounds лучше возвращать и это значение тоже, а от closest_common_parent избавиться? Или, если не хочется выполнять там дополнительные операции, перейти на объектный подход:

if (editor_window.getSelection){ // Gecko, Opera
{
...
return {start: start, end: end, getContainer: function() {
return range.commonAncestorContainer;
}};
}
else if (editor_window.document.selection) // MSIE
{
...
return {start: start, end: end, getContainer: function() {
return range.parentElement();
}};
}

Аналогично, кстати, можно было бы добавить методы для взятия первого выделенного объекта и вставки текста. Тогда весь браузеро-зависимый код оказался бы в одном месте, а в остальных местах можно было бы работать без проверок.

Ну а если совсем правильно, то можно вообще сделать два класса — SelectionDOMImpl и SelectionIEImpl. Первый будет принимать при инициализации Range-объект, а второй — TextRange. Тогда достаточно будет создать объект нужного типа, а дальше уже работать с отмеченной областью независимо от браузера.

спустя 37 минут [обр] Сергей Круглов[досье]
Владимир Палант[досье]
Вот про parentElement был не в курсе, спасибо, такой "обходчик" вместо него намутил... С утра потестирую работоспособность...
К сожалению иногда parentElement не работает так как нужно - пришлось тот самый обходчик писать.
спустя 8 минут [обр] Сергей Круглов[досье]

Насчет
removeAttribute(GECKO?'class':'className') -> className=''

В результате мы получим всякие <p class=""> вместо <p>

спустя 11 часов [обр] Владимир Палант[досье]
Сергей Круглов[досье]
Вы правы, без removeAttribute('className') не обойтись :-(
Только давайте все-таки опустим здесь проверку браузера...
спустя 15 минут [обр] Сергей Круглов[досье]
Владимир Палант[досье]
Вроде removeAttribute при попытке убить несуществующий атрибут ничего такого этакого вызвать не может, поэтому есть мнение... Ой... Полез наверх посмотреть, а там мое мнение уже воплощено :))
спустя 11 часов [обр] Сергей Круглов[досье]

Хм, я не могу в MSIE писать в редактор текст через body.innerHTML, т.к. body в пустом фрейме не существует. Если я его туда вписываю через document.write, MSIE работает на ура, но начинает печалиться gecko, который показывает курсор с часиками и вырубает designMode.

<iframe id="wysiwyg_frame"></iframe>
<script>
onload=function(){
var wysiwyg=document.getElementById("wysiwyg_frame")
if(wysiwyg.offsetWidth){ // wysiwyg is visible now
wysiwyg.contentWindow.document.designMode='On'
//if(GECKO){
//wysiwyg.contentWindow.document.execCommand("useCSS",false,true)
//} // как бы это без проверки браузеров замутить?

wysiwyg.contentWindow.document.write("<body>first content</body>") // пресловутая строка, оставляем - Gecko, убираем - MSIE

wysiwyg.contentWindow.document.body.innerHTML="<p id=xx class=oops>second</p><p>content</p><p>333</p>"
}
}
</script>

document.open() - close() пробовал.

А то опять вернусь к старому своему железобетонному решению, когда у iframe src на файл со всей байдой :)

Напомните, плиз, как DOM-методами к документу <body> добавить.

спустя 1 минуту [обр] Сергей Круглов[досье]
либо мне нужно document.write("<body></body>") только в MSIE :(
спустя 40 минут [обр] Владимир Палант[досье]

Сергей Круглов[досье]
А если:

<iframe src="javascript:'&lt;html&gt;&lt;body&gt;&lt;/body&gt;&lt;/html&gt;'">

Элегантней было бы использовать data:, но IE его не поддерживает...

спустя 2 часа 12 минут [обр] Сергей Круглов[досье]

Я тут о чем подумал...
Я вспомнил, что я не просто так грузил именно файл...

Дело в том, что этот файл в идеале имеет путь, соответствующий пути редактируемой страницы с точностью до QUERY_STRING.

Таким образом я добиваюсь правильных относительных ссылок. Если у меня вставляется ссылка типа about/, wysiwyg сам превращает ее в http://lalala/blfblf/about/, а при переключении с визуального режима на текстовый я в innerHTML заменяю все ="http://lalala/blfblf/ на ="

Можно (нужно) вместо replace сделать и понавороченнее, чтоб обрабатывались и ../