• XSL Tracer

    Я, наконец-то, более-менее довёл до ума свой новый проект, которым хочу поделиться с народом.

    Проект называется XSL Tracer. Его задача заключается в том, чтобы ответить на простой вопрос: откуда после XSL-трансформации пришёл тот или иной элемент? Как это работает можно увидеть на демо-странице. Там отображается результат XSL-преобразования — обычный HTML-документ. Кликнув по любому элементу можно получить подробную информацию о нём:

    • XSL: шаблон, где выводится элемент.
    • Context: контекстный XML (на который сработал матч, в случае xsl:apply-templates). Сам элемент выводится урезанным (только 10 внутренних тэгов), чтобы в случае, если это будет корневой элемент, браузер не умер в муках (да и толку от полной структуры никакого).
    • Source: место в шаблоне, где непосредственно генерируется выбранный элемент. Очень удобно в случае, если делаете сopy-of результатирующей структуры (в примере это html-код, заданный внтутри переменной).
    • Call stack: стэк вызовов apply-template/call-template/apply-imports, через который дошли до вызова текущего шаблона
    • Inner calls: список внутренних вызовов шаблонов, которые по каким-то причинам нельзя отобразить (по крайней мере пока) в документе. Например, вызов шаблонов, выдающих атрибуты для выбранного элемента, или его текстовое содержимое. Список отображается под Call stack, и только в том случае, если такие вызовы есть.

    Демка нормально работает в Safari/Firefox/Chrome, Opera жутко тормозит на больших документах, а в IE даже не открывал.

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

    Как это работает

    Проект состоит из двух частей: бэкэнд и фронтэнд.

    На бэкэнде используется Saxon 6.5 (пока эта версия, так как в 9.2 лично у меня вылезло куча проблем с EXSLT). У него есть встроенный механизм трассировки, который по умолчанию генерирует результат в виде XML (такой файл на реальных проектах вырастает до нескольких мегабайт). Я написал свой трассировочный класс, который выводит данные в более компактном и лёгком JSON-формате, а также дополнительные данные вроде списка используемых в трансформации XSL и XML (подключённых через document()) документов. А также он делает дополнительные штуки вроде резолвинга result-tree фрагментов при вызове xsl:copy-of.

    Этот трассировщик генерирует JSON-документ и результатирующий документ (то есть результат предобразования) и собирает их в одном HTML-файле, который можно открывать в браузере.

    Дальше за работу берётся фронтэнд, который связывает трассировочные данные с результатом трансформа и обеспечивает всю работу интерфейса.

    Как запускать

    1. Должна быть установлена Java (версия особо не важна).
    2. Скачиваем Saxon 6.5
    3. Скачиваем трэйсер
    4. Генерируем трассировку вот такой командой в консоли:

    java -classpath /path/to/tracer.jar:/path/to/saxon.jar ru.imobilco.XSLTracer -to /path/to/trace-result.html /path/to/input.xml /path/to/template.xsl

    В случае успешной работы трэйсера сгенерируется файл trace-result.html, который можно сразу открывать в браузере (CSS и JS берутся с моего сервера). Если возникла ошибка во время трансформа, то файл будет сгенерирован, но вместо документа увидите нормальное сообщение об ошибке. В случае ошибки работы Java, увидите сообщение в консоли.

    Надо помнить, что Saxon очень чувствителен ко всяким всяким отхождениям от спецификации, поэтому то, что работало в Xalan/libxsl/где-то ещё может не сработать в Saxon. Но исправления, как правило, довольно мелкие и незначительные.

    Онлайн-версия

    Мой коллега Лёха Баранов написал сервлет, который позволяет запускать через веб, передавая ссылки на XML и XSL файлы. Для него был заведён специальный проект xmltools.ru. Работает просто: указываете ссылки на файлы и, если нужно, данные для http-авторизации. Далее сам трансформ находит все зависимые файлы, скачивает их и делает трансформ.

    Но с онлайн-версией не так всё просто. В частности, когда результат отдаётся фронтэнду, он тоже начинает скачивать внешние файлы через аякс. И, как вы уже, наверно, догадались, включается ограничение Same-Origin Policy, которое не позволяет загружать данные с другого домена. Вариантов решения два: либо вы свой сервер настраиваете на cross-domain ajax, либо используете xmltools.ru как сервис, запрашивая трансформ через какой-нибудь php/python/ruby скрипт, отдавая результат трассировки со своего домена.

    Онлайн-версия пока находится в состоянии «альфа», поэтому может быть много багов. И пока бесплатна. Подчёркиваю, пока бесплатна. Дело в том, что такие трассировки генерируют очень большую нагрузку, и если она возрастёт до каких-то критических значений, то придётся прикрыть лавочку и предоставлять услугу на коммерческой основе.

    Метки: , , ,
  • От простого к сложному

    Насколько сложной может быть форма из двух полей, отправляющая данные аяксом на сервер? С современными фреймворками вроде jQuery, код может выглядеть примерно так:

    $.post('my-url.php', $('form#my-form').serialize());
    

    Но это минимальный код; код из серии «создаём свой блог/цмс за 5 минут», которым так любят заманивать создатели всяких движков и фреймворков. А что нужно сделать, чтобы такая форма стала удобной и понятной для конечного пользователя, то есть «человеческой»?

    Недавно мы запустили новый скидочный проект BigBuzzy. Так как на рынке уже существует масса подобных проектов, единственный способ выделиться из толпы — это сделать проект очень удобным для пользователя, как с концептуальной точки зрения (не надо ждать, пока наберётся определённая группа; удобные способы оплаты), так и с технологической.

    Одна из фич проекта — отправка купленного купона себе на телефон по СМС. Так как на месте проведения акции нужно предъявить только номер купона, нет особого смысла печатать его на бумаге, можно показать и с телефона. Увидеть форму в действии можно на бигбаззи, купив 10 купонов на этой странице. В этой форме как раз два поля: код и номер телефона. Посмотрим, что можно в ней улучшить.

    Алгоритм работы

    Прежде, чем приступать к работе, нам нужно определиться, какие функции несёт в себе эта форма. Когда пользователь оплатил купон, ему предоставляется на выбор два действия: распечатать или отправить по СМС. Чтобы отправить по СМС, нужно знать номер телефона пользователя. В принципе, можно заставить его ввести этот номер при регистрации, однако это с большой долей вероятности отпугнёт потенциальных покупателей: с чего это вдруг сайт требует с меня номер телефона? Поэтому форму будем показывать только тогда, когда пользователь нажмёт на кнопку «Отправить по СМС»: в этом случае ему будет понятно, почему требуется ввести номер телефона.

    А что делать, если пользователь уже ввёл номер при отправке первого купона и хочет отправить второй? Логично, что он захочет отправить купон на тот же самый номер. Нужно ли в этом случае показывать форму? Решаем, что нужно. Во-первых, пользователь может захотеть отправить купон своему другу в качестве подарка или этот друг только что позвонил и попросил выручить, докупив ещё два купона на боулинг. Заставлять пользователя идти в Личный кабинет и менять там номер телефона как-то уж совсем неправильно. Во-вторых, появляющаяся форма будет своеобразным подтверждением действия (вдруг он случайно нажал на эту кнопку?). Поэтому на нажатие конпки будем запускать анимацию появления формы, а повторное нажатие на эту же кнопку отправить данные на сервер:

    $(document.body).delegate('.coupon-button', 'click', function(/* Event */ evt) {
    	var elem = $(this);
    	if (elem.hasClass('coupon-button-sms')) {
    		var coupon = elem.closest('.coupon');
    		if (!coupon.length)
    			return;
    
    		if (!coupon.hasClass('coupon-sms-mode')) {
    			// переключаем купон в режим отправки СМС
    			switchSMSMode(coupon);
    		} else  {
    			// пытаемся отправить данные на сервер
    			tryToSendSMS(coupon);
    		}
    	}
    });
    

    Функцию анимации появления формы с вводом номера телефона я тут не привожу, так как она довольно большая и скучная (желающие могут посмотреть исходник). Первая проблема, которая может возникнуть на этом шаге: пользователь может случайно дважды кликнуть на кнопку, что приведёт к моментальной отправке СМС, если там уже был вбит номер. Поэтому нужно поставить блокировку на выполнение действия. Это стандартный подход в анимированных интерфейсах, который позволяет избежать случайных нажатий или даже поломки всего интерфейса (может наложиться несколько анимаций друг на друга). Введём переменную is_animating, которая будет равна true в момент анимации. Обнулять эту переменную будем после завершения анимации через callback-функцию для switchSMSMode():

    $(document.body).delegate('.coupon-button', 'click', function(/* Event */ evt) {
    	var elem = $(this),
    		is_animating = false;
    
    	if (elem.hasClass('coupon-button-sms')) {
    		var coupon = elem.closest('.coupon');
    		if (!coupon.length || is_animating)
    			return;
    
    		if (!coupon.hasClass('coupon-sms-mode')) {
    			// переключаем купон в режим отправки СМС
    			is_animating = true;
    			switchSMSMode(coupon, function(){
    				is_animating = false;
    			});
    		} else  {
    			// пытаемся отправить данные на сервер
    			tryToSendSMS(coupon);
    		}
    	}
    });
    

    Ввод данных

    Форму мы показали, теперь нужно упростить пользователю ввод данных. Тут надо помнить, что существует множество моделей поведения пользователя. Кто-то быстро введёт код и переключится табом на следующее поле, кто-то будет минуту набирать три цифры и схватиться за мышку, чтобы переключиться на следующее поле, кто-то будет ожидать, что введённая последовательность из 10 цифр автоматически распределиться между полями. Нужно постараться удовлетворить максимальное количество пользователей. Поэтому будем следить за полем «код», и когда там будет 3 цифры — автоматически перебросим фокус на поле с номером:

    $('.phone-code').keyup(function(){
    	if (this.value.length == 3)
    		$(this).next('.phone-num').focus();
    });
    

    Так делают многие и так делать ни в коем случае нельзя. Если пользователь допустил ошибку в коде, то попытка исправить её используя только клавиатуру превратиться в сущий ад: например, нажатие на стрелки клавиатуры всегда будет приводить к перебросу фокуса, потому что сработает событие событие keyup, а в поле уже будет 3 символа. Чтобы сделать поведение формы более естественным, нужно следить за вводом данных: перебрасывать фокус будем только если в предыдущем нажатии на кнопку (last_length) было меньше символов, чем в этом нажатии, количество символов равно трём и каретка находится в самом конце поля:

    $('.phone-code').bind('keyup change', function(/* Event */ evt) {
    	var field = $(evt.target),
    		last_length = field.data('last_length') || 0,
    		cur_length = field.val().length,
    		max_length = parseInt(field.attr('maxlength')) || 3,
    		selection = getSelectionRange(field[0]);
    
    	if (cur_length > last_length && cur_length == max_length && selection && selection.start == cur_length) {
    		field.next('.phone-number').focus();
    	}
    
    	field.data('last_length', cur_length);
    });
    
    function getSelectionRange(elem) {
    	if ('selectionStart' in elem) { // W3C's DOM
    		return {
    			start: elem.selectionStart,
    			end: elem.selectionEnd
    		};
    	} else if (document.selection) { // IE
    		elem.focus();
    
    		var range = document.selection.createRange(),
    			content = elem.value;
    
    		if (range === null) {
    			return {
    				start: 0,
    				end: content.length
    			};
    		}
    
    		var re = elem.createTextRange();
    		var rc = re.duplicate();
    		re.moveToBookmark(range.getBookmark());
    		rc.setEndPoint('EndToStart', re);
    
    		return {
    			start: rc.text.length,
    			end: rc.text.length + range.text.length
    		};
    	} else {
    		return null;
    	}
    }
    

    Но тут возникает ещё одна проблема. Далеко не каждый пользователь догадается, что при заполнении поля с кодом фокус перекинется на следующее поле. Он может не глядя на монитор набрать 3 цифры, нажать на таб и набрать ещё 7 цифр. В этом случае наша «помощь» только помешает пользователю, потому что фокус будет где-то совершенно в другом месте. Чтобы такого не случилось, нужно временно заблокировать нажатие на таб на поле с номером телефона после того, как фокус был автоматически переброшен:

    $('.phone-code').bind('keyup change', function(/* Event */ evt) {
    	var field = $(evt.target),
    		last_length = field.data('last_length') || 0,
    		cur_length = field.val().length,
    		max_length = parseInt(field.attr('maxlength')) || 3,
    		selection = getSelectionRange(field[0]);
    
    	if (cur_length > last_length && cur_length == max_length && selection && selection.start == cur_length) {
    		var num_field = field.next('.phone-number');
    
    		// временно блокируем Tab на следующем поле
    		num_field.data('tab_locked', true);
    		setTimeout(function() {
    			num_field.data('tab_locked', false);
    		}, 1000);
    
    		num_field.focus();
    	}
    
    	field.data('last_length', cur_length);
    });
    
    $('.phone-number').bind('keydown keyup keypress', function(/* Event */ evt) {
    	var field = $(evt.target);
    	if (field.data('tab_locked') === true && evt.keyCode == 9) {
    		// блокируем работу клавиши Tab
    		evt.preventDefault();
    	}
    });
    

    Валидация данных

    Перед отправкой данных на сервер нужно сделать простую валидацию данных, чтобы убедиться, что введённые данные похожи на номер телефона. Если нет — то намекнём об этом пользователю.

    Вводить жёсткий формат записи числовых данных вроде номера телефона или кредитной карты — первый признак полового бессилия разработчика. Для номера телефона нам нужно 10 цифр (код + сам номер), и не важно, написаны цифры слитно или разделены пробелами или дефисами. Пользователь должен написать номер в привычном ему формате, чтобы легче было проверять правильность. Поэтому при валидации удалим из полей все не числовые символы и проверим длину того, что осталось:

    function validatePhoneNumber(coupon) {
    	coupon = $(coupon);
    
    	var re_num = /[^0-9]/g,
    		phone_code = coupon.find('.phone-code'),
    		phone_num = coupon.find('.phone-number'),
    		is_valid_code = phone_code.val().replace(re_num, '').length == 3,
    		is_valid_num = phone_num.val().replace(re_num, '').length == 7;
    
    	if (!is_valid_code)
    		blinkField(phone_code);
    	if (!is_valid_num)
    		blinkField(phone_num);
    
    	return is_valid_code && is_valid_num;
    }
    

    Если введённые данные не верны, то мы должны сообщить об этом пользователю. Пространство у нас очень ограничено, поэтому будем «моргать» полем, указывая, что там что-то не так. Функция добавляет и удаляет определённый класс у элемента через заданный промежуток времени, в CSS укажем этому классу красный цвет:

    function blinkField(fld) {
    	fld = $(fld);
    	if (fld.data('blink_timer'))
    		clearInterval(fld.data('blink_timer'));
    
    	var blink_count = 6;
    
    	fld.data('blink_timer', setInterval(function() {
    		fld.toggleClass('coupon-field-warning');
    		if (blink_count-- < 0) {
    			fld.removeClass('coupon-field-warning');
    			clearInterval(fld.data('blink_timer'));
    		}
    	}, 100));
    }
    

    Отправка данных

    Данные от пользователя получили и проверили, настало время отправить их на сервер. Опять же, тут есть две потенциальные проблемы. Первая — это слишком медленный интернет. Нужно показать пользователю, что процесс запущен и скоро что-то произойдёт. Вторая проблема не так очевидна — это слишком быстрый интернет. Настолько быстрый, что пользователь может и не понять, а произошло ли что-то, отправились ли данные или возникла ошибка? Чтобы избавиться от этих проблем, нужно показать показать некий индикатор процесса, причём этот индикатор должен искусственно замедлить получение фидбэка от сервера. Он должен отображаться на странице некоторое время, чтобы пользователь успел понять, что запущен некий процесс.

    Кто-то решает эту задачу так: показывает индикатор, ждёт определённое время и уже после этого отправляет запрос на сервер, а после получения ответа прячет индикатор. Так делать не правильно: в случае с медленным интернетом таймаут слишком сильно увеличивается; сам запрос мог бы запросто отработать за то время, пока показывается индикатор. Поэтому мы устроим небольшую гонку: что последним закончится выполняться — таймер индикатора или запрос на сервер — то и покажет фидбэк пользователю:

    function sendSMSRequest(coupon) {
    	coupon = $(coupon);
    	var phone_code = coupon.find('.phone-code').val(),
    		phone_num = coupon.find('.phone-number').val();
    
    	coupon.addClass('coupon-preloader');
    	// запускаем индикатор
    	preloader.start();
    
    	// два условия отображения фидбэка: закончился таймер и пришёл ответ от сервера
    	var timer_reached = false,
    		response_received = false;
    
    	function checkStatus() {
    		if (timer_reached && response_received) {
    			// останавливаем индикатор
    			preloader.start();
    			coupon.removeClass('coupon-preloader');
    
    			// прячем форму с номером телефона
    			switchSMSMode(coupon);
    		}
    	}
    
    	// устраиваем гонку между таймером и запросом
    	setTimeout(function() {
    		timer_reached = true;
    		checkStatus();
    	}, 1000);
    
    	$.get(sms_url, {
    		id: coupon.attr('id'),
    		action: 'send_sms',
    		phone: phone_code + phone_num
    	}, function(data) {
    		response_received = true;
    		checkStatus();
    	}, 'json');
    }
    

    Вот так, двигаясь от простого к сложному, всего за несколько часов работы мы улучшили форму из целых двух полей, которую не стыдно показывать людям. Можно, конечно, улучшить ещё немного, явно сообщая пользователю о статусе отправки сообщения, но это уже другая история. Для сравнения: что было и что стало.

    code-example

  • Content assist для элемента textarea

    Очередной эксперимент в поисках идеального средства для простого и быстрого написания кода.

    <textarea>, да и веб-технологии в целом привлекают меня своей простотой, доступностью и возможностью сделать так, как нужно мне. Есть Zen Coding, который позволяет быстро и удобно работать с большим объёмом XHTML-данных, но для пущего удобства не хватает автодополнения кода. Поиск в интернете не дал никаких вменяемых результатов, поэтому решил написать свой:

    content-assist

    Проверить автодополнение можно на тестовой странице. Попробуйте написать несколько английских слов, начинающихся на «a» (awesome, animal, amazing). Должно работать везде, включая IE6.

    Само по себе автодополнение, по большому счёту, практически бесполезно: непонятно, в какой момент какие данные нужно показывать. Поэтому решение представляет из себя несколько отдельных классов, каждый из которых можно переопределить, расширить и дополнить. За основу была взята архитектура автодополнения для Eclipse IDE, поэтому ряд классов и методов называются одинаково.

    • TextViewer — по сути, обёртка для <textarea>. Основное предназначение: дать удобный интерфейс для получения содержимого элемента. Один из основных методов — getCharacterCoords(char_ix), возвращает координаты для определённого символа. Метод возвращает объект со свойствами x и y в пикселях, отсчёт идёт от левого верхнего угла <textarea> до левого верхнего угла нужного символа. Метод getAbsoluteCharacterCoords(char_ix) возвращает то же самое, только относительно offsetParent. Как не трудно догадаться, этот класс можно переписать для работы с content-editable блоком, и у вас будет автодополнение для более серьёзного редактора.
    • ContentAssistProcessor — процессор, который высчитывает и предлагает список дополнений. По умолчанию ищет левую и правую границу английского слова (см. метод isAllowedChar), ищет префикс (от левой границы слова до каретки) и предлагет по этому префиксу список дополнений.
    • CompletionProposal — предложение для автодополнения. Главный метод — apply(), который применяет текущее предложение к TextViewer-объекту.
    • ContentAssist — основной класс. Связывает между собой TextViewer и ContentAssistProcessor, а также управляет всплывающим окном с подсказками.
    • tx_utils — набор всяких DOM-утилит.

    Для добавления поддержки автодополнения для <textarea> нужно инициализировать все эти классы:

    var viewer = new TextViewer(document.getElementById('my-textarea'));
    var processor = new ContentAssistProcessor(['word1', 'word2', 'word3']);
    var content_assist = new ContentAssist(viewer, processor);
    

    Для удобства есть класс BasicContentAssist, который делает всё то же самое для простых случаев (автодополнение по фиксированному словарю):

    new BasicContentAssist(document.getElementById('my-textarea'), ['word1', 'word2', 'word3']);
    

    У класса ContentAssistProcessor есть метод completitionProposalFactory, который должен возвращать объект класса CompletionProposal. Внешний вид попапа с предложениями был нагло позаимствован из редактора Espresso и полностью управляется через CSS. Окошко с предложениями можно вызвать принудительно по Ctrl+Space (не работает в Опере и немного тупит в Файерфоксе) либо по Alt+Space (хвала разработчикам Оперы: это тоже не работает, по крайней мере на Маке) и заменить текущее слово (зависит от позиции каретки).

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

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

    Метки: ,
  • Как создавалась Айчиталка. Часть 1: движок

    Мы потихоньку переходим на Хабр с корпоративным блогом Аймобилко, где я и мои коллеги будем рассказывать о том, как создавались наши сервисы. Первая статья: рассказ про создание движка онлайн-читалки booq (так называется движок, сам сервис называется Айчиталка).

    Пока пишу с корпоративного аккаунта, а если суппорт Хабра таки отдуплится и поправит баги, то буду писать со своего.

  • Узнаём строку CSS-правила в Safari/WebKit/Chrome Web Inspector

    В браузерах на движке WebKit есть замечательный инструмент для отладки веб-страниц — Web Inspector. По сути, это аналог Firebug, со своими сильными и слабыми сторонами. В последнее время я всё чаще предпочитаю пользоваться Web Inspector’ом, однако один его недостаток постоянно не давал мне покоя — это отсутствие номера строки редактируемого CSS-правила:

    css-tab

    Если погуглить, то можно убедиться, что не одного меня беспокоит эта проблема. Руководствуясь правилом «если хочешь что-то сделать — сделай это сам» я написал небольшой хак, который позволяет узнать номер строки CSS-правила:

    Web Inspector CSS hack

    Установка довольно простая: нужно всего лишь подключить файл SC-CSSAdditions.js в inspector.html (Safari, WebKit) или devtools.html (Google Chrome). Подробная инструкция написана на странице проекта.

    Как это работает

    Весь Web Inspector практически полностью базируется на возможностях браузера, предоставляемых спецификацией W3C. Соответственно, номер строки CSS-правила просто так не узнаешь. Я написал альтернативный CSS-парсер, который достаёт из исходника правила, но при этом сохраняет их позицию в коде. Перед тем, как Web Inspector выводит список правил, принадлежащих элементу, происходит сопоставление этих правил с моими (по позиции в списке или по селектору) и к имени файла добавляется номер строки:

    Пока на всех протестированных мной сайтах проблем не обнаружено. Максимум, что может произойти — это выведется неправильный номер строки. Если найдёте такую проблему, напишите, пожалуйста, мне в багтрэкер и не забудьте приложить пример CSS-кода.

← старое новое →