Главная > Счётчик оставшихся символов для формы
На этой странице показано одно сообщение с комментариями к нему, самые новые комментарии снизу.

115 месяцев назад

Постоянная ссылка Vanav

Категории

Счётчик оставшихся символов для формы

Задача: сделать счётчики оставшихся символов для полей формы ограниченной длины, в частности <input type="text"> и TinyMCE.

Показывать под формой сообщение: «Осталось 25 символов» — и адекватно его обновлять. В решении будут поддерживаться все основные движки браузеров, и изменение содержимого формы будет ловиться гораздо более точно, чем событием onkeyup.

Пример:


 

Итак, главная проблема такого рода скриптов — поймать событие изменения формы, а оно включает в себя:

  • нажатие буквенных клавиш,
  • вставка текста из буфера обмена клавиатурой или мышью,
  • удаление или вырезание выделенного текста клавиатурой или мышью,
  • перемещение мышью выделенного фрагмента текста (drag’n’drop).

Для случая с <input> будет изменяться атрибут value, для случая TinyMCE будет изменяться DOM дерево.

<input type="text">

Движок Gecko поддерживает нестандартное событие input, которое пришло из XUL:

This event is sent when a user enters text in a textbox. This event is only called when the text displayed would change, thus it is not called when the user presses non-displayable keys.

(XUL Events.)

Также выяснилось, что это событие поддерживают WebKit и Opera. WebKit также поддерживает события DOMSubtreeModified и paste, а Opera — DOMControlValueChanged. Все они вызываются в нашем случае, но нет необходимости.

Gecko пропускает событие при перетаскивании текста мышью, поэтому добавим для него событие dragdrop.

IE поддерживает нестандартное событие onpropertychange:

The onpropertychange event fires when properties of an object, expando, or style sub-object change. <…> When the onpropertychange event fires, the srcElement property of the event object is set to the object whose property has changed.

(MSDN onpropertychange Event.)

Как fallback будем использовать событие keyup (наиболее простое) и DOMCharacterDataModified (на мой взгляд самое правильное из стандартных событий W3C, определено в DOM Level 3 Draft, MutationEvent). По факту, необходимый текст является атрибутом элемента <input>, и должен вызываться DOMAttrModified, но ни один из движков с этим не согласился.

К сожалению, определить программно поддерживает ли браузер событие или нет не просто: нужно создавать тестовый элемент, вешать событие, программно вызывать и смотреть результат. Поэтому будем определять движок на основе объектной модели JavaScript.

JavaScript
function inputlineAttachCharCounter(ed) {
    // Gecko only suppports 'input' (XUL)
    // WebKit: 'input', parentNode.'DOMSubtreeModified', 'paste'
    // IE: onpropertychange
    // Opera: DOMControlValueChanged, input
 
    el = document.getElementById(ed);
 
    // First check most speific engines, and end with generic standard events.
    if (BEDetector.engine.is.Gecko || BEDetector.engine.is.WebKit || BEDetector.engine.is.Opera) {
        el.addEventListener('input',
            function() { inputlineUpdateCharCounter(this) },
            false);
        // Help Gecko
        el.addEventListener('dragdrop',
            function() { inputlineUpdateCharCounter(this) },
            false);
    } 
    else if (BEDetector.engine.is.MSHTML) {
        el.attachEvent('onpropertychange',
            function() { inputlineUpdateCharCounter(null)} );
    } else {
        // Fall to generic standard support
        el.addEventListener('keyup',
            function() { inputlineUpdateCharCounter(this) },
            false);
        // maybe KHTML (DOMCharacterDataModified, also DOMSubtreeModified)
        el.addEventListener('DOMCharacterDataModified',
            function() { inputlineUpdateCharCounter(this) },
            false);
    }
}

Остался простой код по выполнению всяких служебных действий.

JavaScript
<script type="text/javascript" src="/js/bedetector.js"></script>
<script type="text/javascript">
/* If <input> text id is "message-subject", empty <div> for "... characters left" 
   must have id "msgCounter_message-subject". The same for tinymce.
*/
// Constants
var tinymceMaxChars = 500;
var inputlineMaxChars = 50; // Also update maxlength="..." attribute of <input>.
// Prototype library, may be used without it.
document.observe("dom:loaded", function() {
    inputlineAttachCharCounter('message-subject');
    inputlineUpdateCharCounter($('message-subject'));
});
</script>

TinyMCE

Специфика счётчика для TinyMCE в том, что он модифицирует DOM, и нужно решать иные задачи: повесить событие на изменение DOM, найти длину текста без тегов, корректно запретить превышение лимита.

Событие изменения DOM

Поскольку нас интересуют только видимые символы, а не теги, самое подходящее событие стандартного W3C DOM — DOMCharacterDataModified. Его поддерживает Gecko и WebKit, также эти движки поддерживают подходящие для нашего случая DOMSubtreeModified и paste. Но у Gecko обнаружилась проблема, он иногда терял событие, поэтому для него я использовал более общее DOMSubtreeModified.

IE к сожалению ничего не может предложить для отслеживания изменения DOM, поэтому будем выходить из положения следующим набором: onkeyup, onpaste, oncut, ondragend.

Как fallback и для Opera (она поддерживает только keyup) будем использовать keyup и DOMCharacterDataModified.

JavaScript
function tinymceAttachCharCounter(ed) {
    /*
       tinymce editor (ed.getBody()), it is a <body> with DOM in editorMode.
       Gecko: DOMCharacterDataModified, DOMSubtreeModified, paste
       WebKit: DOMCharacterDataModified, DOMSubtreeModified, paste
       Opera: keyup only
       IE: onkeyup, onpaste, oncut, ondragend
     */
    el = ed.getBody();
 
    // First check most speific engines, and end with generic standard events.
    if (BEDetector.engine.is.Gecko) {
        // Gecko sometimes looses DOMCharacterDataModified
        el.addEventListener('DOMSubtreeModified',
            function(e) {
                return function() { tinymceUpdateCharCounter(e) }
            }(ed),
            false);
    } 
    else if (BEDetector.engine.is.WebKit) {
        el.addEventListener('DOMCharacterDataModified',
            function(e) {
                return function() { tinymceUpdateCharCounter(e) }
            }(ed),
            false);
    } 
    else if (BEDetector.engine.is.MSHTML) {
        el.attachEvent('onkeyup',
            function(e) {
                return function() { tinymceUpdateCharCounter(e) }
            }(ed) );
        el.attachEvent('onpaste',
            function(e) {
                return function() { tinymceUpdateCharCounter(e) }
            }(ed) );
        el.attachEvent('oncut',
            function(e) {
                return function() { tinymceUpdateCharCounter(e) }
            }(ed) );
        el.attachEvent('ondragend',
            function(e) {
                return function() { tinymceUpdateCharCounter(e) }
            }(ed) );
    } else {
        // Fall to generic standard support, also Opera is here
        el.addEventListener('keyup',
            function(e) {
                return function() { tinymceUpdateCharCounter(e) }
            }(ed),
            false);
        // Not Opera, maybe KHTML (DOMCharacterDataModified, also DOMSubtreeModified)
        el.addEventListener('DOMCharacterDataModified',
            function(e) {
                return function() { tinymceUpdateCharCounter(e) }
            }(ed),
            false);
    }
}

Отдельно хочу отметить особую конструкцию языка, которую я здесь применил: двойное замыкание. Ниже упрощённый пример конструкции:

JavaScript
function tinymceAttachCharCounter(ed) {
    ed.addEventListener('DOMSubtreeModified',
        function(e) {
            return function() { tinymceUpdateCharCounter(e) }
        }(ed),
        false);
}

Функции в JS имеют доступ к контексту, в котором они определены (за исключением переменных this и arguments). Казалось бы, что мешает сделать просто:

JavaScript
// Ошибочный пример
function tinymceAttachCharCounter(ed) {
    ed.addEventListener('DOMSubtreeModified',
        function() { tinymceUpdateCharCounter(ed) }
        false);
}

Но время жизни внутренней функции (обработчика события function()) больше, чем внешней (назначающей обработчики tinymceAttachCharCounter(ed). Внутренняя функция (замыкание) получает доступ к переменным внешней функции по ссылке, а не по значению. Поэтому в момент исполнения обработчика function() { tinymceUpdateCharCounter(ed) } будет использоваться текущая переменная ed, а не та, что была в момент создания. Если позже во внешней функции или в любом вызове внутренней функции переменная ed изменится, то внутренняя функция перестанет работать. Хотя внешняя функция давно завершилась, время жизни её используемых переменных расширяется до времени жизни внутренних функций.

Мне нужно привязать созданную функцию к ed в момент вызова, поэтому я создаю функцию, и тут же вызываю её, передавая в неё текущую переменную ed — двойное замыкание (см. первый пример). Теперь у каждой внутренней функции будет своя, независимая от внешней функции переменная ed.

Длина текста без тегов и ограничение длины редактора

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

Ограничение длины текста в редакторе можно было бы сделать через метод ed.execCommand('Undo'), но он отменяет гораздо больше, чем последний нажатый символ, поэтому я реализовал собственный движок для одноуровнего Undo.

JavaScript
function updateCounterText(id, n) {
    var el = document.getElementById(id);
    if (n <= 0) {
        el.innerHTML = '<span style="color:#8a1f11">No characters left.</span>';
    } else {
        if (n == 1)
            el.innerHTML = "1 character left.";
        else
            el.innerHTML = n + " characters left.";
    }
}
 
function tinymceGetLength(oEditor) 
{
    // Get the Editor Area DOM (Document object).
    var oDOM = oEditor.getDoc() ;
 
    var iLength ;
    // The are two diffent ways to get the text (without HTML markups).
    // It is browser specific.
    if (document.all) {
        // If Internet Explorer.
        iLength = oDOM.body.innerText.length;
    } else {
        // If Gecko.
        var r = oDOM.createRange() ;
        r.selectNodeContents(oDOM.body);
        iLength = r.toString().length;
    }
 
    return iLength;
}
 
function tinymceUpdateCharCounter(ed)
{
    // To make things faster, remove bookmarks.
    currCount = tinymceGetLength(ed);
    remainCount = tinymceMaxChars - currCount + 1; //fix: allow +1
 
    if (remainCount <= 0) {
        // Need to use own undo for small events.
        //ed.execCommand('Undo');
        if (tinymceUndoBuffer[ed.editorId]) {
            ed.setContent(tinymceUndoBuffer[ed.editorId]);
            ed.selection.moveToBookmark(tinymceUndoBookmark[ed.editorId]);
        }
    } else {
        tinymceUndoBuffer[ed.editorId] = ed.getContent();
        tinymceUndoBookmark[ed.editorId] = ed.selection.getBookmark();
    }
 
    //-1 to counteract above +1
    updateCounterText('msgCounter_' + ed.editorId, remainCount - 1);
}

При создании TinyMCE я вызываю свои функции инициализации по собственному событию редактора onInit, и поскольку не во всех движках удалось добиться хорошего обнаружения изменений DOM, помогаю внутренним событием onChange, но оно вызывается редко (в момент создания уровня внутреннего Undo).

JavaScript
<script type="text/javascript" src="/js/bedetector.js"></script>
<script type="text/javascript">
/* If <input> text id is "message-subject", empty <div> for "... characters left" 
   must have id "msgCounter_message-subject". The same for tinymce.
*/
// Constants
var tinymceMaxChars = 500;
 
tinyMCE.init({
    mode : "exact",
    elements : "details",
    setup : function(ed) {
        ed.onInit.add(function(ed) {
            tinymceAttachCharCounter(ed);
            tinymceUpdateCharCounter(ed);
        });
        ed.onChange.add(function(ed, l, um) {
            // Help Opera here, will help handle paste, but delayed
            tinymceUpdateCharCounter(ed);
        });
    }
});
</script>

Вот и всё, если я упустил какие-либо события, или есть комментарии, прошу.

Комментарии