Пишем «рисовалку» для iPhone на HTML5 Canvas

:

Здравствуйте, Хабражители!

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

  • об особенностях событий touch-устройств;
  • об особенностях верстки для мобильных девайсов;
  • почему для создания нормальной «рисовалки» нужно использовать несколько холстов;
  • что такое clickjacking и зачем я использовал этот хак в своей рисовалке;
  • о всех трудностях и некоторых мелочах, с которыми я столкнулся в процессе разработки;

Больше — под катом

Вступление

Я думаю, что каждый когда-либо интересовался и разработкой под iPhone, и технологией HTML5 Canvas, но по разным причинам забросывал. В этой статье я хочу показать, что сделать такое приложение очень легко. Для прозрачности кода, я не стал использовать сторонние библиотеки и фреймворки.
Итак, начнем.

CSS-стили

Сначала думал начать с html кода, но понял, что для полного понимания, что происходит, сперва нужно предоставить стили.

body {
margin: 0;
padding: 0;
background: #fff;
}
/* Верхняя панель, на которой расположены кнопки */
.tb {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 40px;
overflow: hidden;
border-bottom: 1px solid #CCC;
background-color: orange;
}
canvas {
position: absolute;
top: 40px;
left: 0;
}
/* Стили кнопки */
.bt {
overflow: hidden;
float: left;
font-weight: bold;
color: white;
font: 16px Arial;
width: 33%;
padding-top: 10px;
height: 30px;
text-align: center;
}
/* Стили элемента select */
select {
float: left;
width: 33%;
margin-top: 10px;
height: 20px;
}

HTML-файл

Для сохранения адекватного размера топика, я решил не публиковать весь файл, а только интересные его части.
Meta viewport
...
<head>
  <meta name="viewport" content="width=device-width,user-scalable=no" />
</head>
...

Тег meta с атрибутом viewport указывает мобильному браузеру, какая ширина будет у страницы (width), высота (height), разрешить ли пользователю менять зум на данной странице (значение user-scalable), с каким начальным зумом загружать страницу (initial-scale), какой может быть минимальный (minimum-scale) и максимальный (maximum-scale) зум. Ширина и высота текущего девайса хранится в значениях device-width/device-height.

Делаем выбор цвета / размера кисти

Я долго думал над тем, как сделать выбор цвета, и самое простое, что пришло в голову, создать элемент select, с нужными значениями цвета и размеров в тегах option и по нажатию на кнопку «Цвет» или «Размер» просто слать фокус элементу select. Известно, тег select при фокусе на айфоне работает примерно так.

Фокус (метод . focus()), к сожалению, слаться не хотел. Но и я не сдавался! И вот что подумал: а что если сделать прозрачный див, в котором будут находится нужные мне элементы select. А див этот, в свою очередь, наложить на мои кнопки?! И что Вы думаете? Все работает! Для наглядности, показываю как:

Привожу код хака:


<!-- то, что видит пользователь -->
<div id="tb" style="z-index: 2">
  <div class="bt">Цвет</div>
  <div class="bt">Толщина</div>
  <a class="bt">Сохранить</a>
</div>

<!-- то, что видит браузер -->
<div id="htb" style="opacity: 0; z-index: 3">
  <select id="hcs">
      <option>blue</option>
      <option>red</option>
      <option>green</option>
      <!-- Урезано для сокращения размеров файла -->
  </select>
  <select id="hss">
      <option>10</option>
      <option>11</option>
      <option>12</option>
      <!-- Урезано для сокращения размеров файла -->
  </select>
  <a class="bt" id="savebutton" style="z-index: 20;"></a>
</div>

(эта штука называется clickjacking, если прозрачный фрейм с другого сайта, например)

Самое интересное

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

События touch-устройств

ontouchstart — срабатывает, когда пользователь только начал движение пальцем по экрану (можно сравнить с onmousedown на компьютере).

ontouchmove — срабатывает, когда пользователь ведет пальцем по экрану (можно сравнить с onmousemove).

ontouchend — срабатывает, когда пользователь отпустил палец от экрана (можно сравнить с onmouseup).

Так-же есть ongesturechange, ongestureend из Gesture API, но они в данной статье рассматриваться не будут.

Каждое событие возвращает массив touches, каждый элемент которого содержит такие свойства как:
pageX, pageY, clientX, clientY. Количество элементов в массиве touches зависит от количества пальцев, которые касаются экрана.

Поэтому отследить касание экрана и получить координаты можно очень просто:

someElement.ontouchstart = function(e) {
 console.log("X: " + e.touches[0].pageX + ", Y:" + e.touches[0].pageY);
}
Алгоритм

Как все будет работать? По пунктам
  • При срабатывании события ontouchmove / ontouchstart холст очищается, в массив, который состоит из координат движений пальцев записывается ещё одно значение и все заного перерисовывается.
  • При срабатывании события ontouchend все, что нарисовано на основном холсте сохраняется в изображение, и это изображение копируется на прозрачный холст-помощник, который лежит под основным холстом. После этого, основной холст очищается, и ждет нового ontouchmove/ontouchstart

Почему всё так сложно?
Дело в том, что в функции отрисовки используются кривые Безье, т.е. кривая(чаще — ей конец) может измениться, если следующая контрольная координата будет в другой стороне. Если же использовать обычные прямые линии то при быстрых круговых движениях можно будет наблюдать не красивые углы.
Зачем все перерисовывать на второй канвас

Как я уже сказал, после каждого «штриха» изображение с основного холста клонируется на холст-помощник. Это делается для того, чтобы не забивать массив координат для основного холста. Так как чем больше контрольных точек в массиве — тем медленнее будет перерисовываться наш рисунок. Из этих соображений я ограничил буфер перерисовки до 1 штриха (если рисовать один большой штрих, то скорость тоже может снизиться).
Код

Код создания холстов и получения контекстов
var width = window.innerWidth; //ширина телефона
var height = window.innerHeight; //высота телефона

var hcanv = document.createElement("canvas"); // создаем канвас рас 
var mcanv = document.createElement("canvas"); // создаем канвас два 

//выставляем канвасам размеры
mcanv.width = width; 
mcanv.height = height;
hcanv.width = width;
hcanv.height = height;

//добавляем канвасы в DOM
document.body.appendChild(mcanv);
document.body.appendChild(hcanv);
hcanv.style.zIndex = 10; //делаем один канвас выше другого 

//получаем контексты
var mctx = mcanv.getContext("2d");
var hctx = hcanv.getContext("2d");
Обработка выбора цвета и размера кисти
var selects = document.getElementsByTagName("select"); //получаем все элементы select
for(var i=0; i<selects.length; i++) { //пробегаем циклом 
  selects[i].onchange = handleSelects; // и вешаем события на выбор значения
}

function handleSelects() { //функция обработки выбранного значения
  var val = this.options[this.selectedIndex].value;
  switch(this.id) { //проверяем что это, выбор цвета или размера
    case "hcs": 
      brush.color = val; // выставляем значение
    break;
    case "hss":
      brush.size = val; // выставляем значение
    break;
  }
}
Код рисования
var touches = {x:[], y:[]}; // массивы координат
var brush = {color: "blue", size: 10}; //текущие настройки кисти
var snapshot = ""; //тут будет храниться изображение


hcanv.ontouchstart = function(e) { //пользователь коснулся экрана
  //добавляем координаты в массивы
  touches.x.push(e.touches[0].pageX);
  touches.y.push(e.touches[0].pageY-40); //40 пикселей - высота верхней панели
  hctx.clearRect(0, 0, width, height); //очищаем канвас
  redraw(hctx); // рисуем (точечки)
  return false; // отменяем действие браузера по-умолчанию
}

hcanv.ontouchmove = function(e) { //пользователь повел пальцем
  //добавляем координаты в массивы
  touches.x.push(e.touches[0].pageX);
  touches.y.push(e.touches[0].pageY-40);
  hctx.clearRect(0, 0, width, height);  //очищаем канвас
  redraw(hctx); //рисуем (линии)
  return false; // отменяем действие браузера по-умолчанию (скроллинг etc)
}

hcanv.ontouchend = function(e) { //пользователь оторвал палец от экрана
  snapshot = hcanv.toDataURL(); // получаем URL представление канваса в png
  var img = new Image(); // создаем новую картинку
  img.src = snapshot; // назначаем ей url
  img.onload = function() { //когда она загрузится (а на айфоне это не моментально)
    mctx.drawImage(img, 0, 0); //рисуем картинку на втором канвасе
    hctx.clearRect(0, 0, width, height);  //очищаем первый канвас
  }
  //очищаем массивы
  touches.x = []; 
  touches.y = [];
}

//функция перерисовки
function redraw(ctx) {
  ctx.lineCap = "round"; //вид конца линии
  ctx.lineJoin = "round"; //вид излома
  ctx.strokeStyle = brush.color; //цвет линии
  ctx.lineWidth = brush.size; // размер линии
  ctx.beginPath();
  if(touches.x.length < 2) { //проверяем, не нарисовал ли пользователь точку
    ctx.moveTo(touches.x[0], touches.y[0]); 
    ctx.lineTo(touches.x[0] + 0.51, touches.y[0]);
    ctx.stroke();
    ctx.closePath(); 
    return;
  }
  ctx.moveTo(touches.x[0], touches.y[0]); 
  ctx.lineTo((touches.x[0] + touches.x[1]) * 0.5, (touches.y[0] + touches.y[1]) * 0.5); 
  var i = 0;
  while(++i < (touches.x.length -1)) {
    var abs1 = Math.abs(touches.x[i-1] - touches.x[i]) + Math.abs(touches.y[i-1] - touches.y[i]) 
    + Math.abs(touches.x[i] - touches.x[i+1]) + Math.abs(touches.y[i] - touches.y[i+1]);
    var abs2 = Math.abs(touches.x[i-1] - touches.x[i+1]) + Math.abs(touches.y[i-1] -  touches.y[i+1]);
      if(abs1 > 10 && abs2 > abs1 * 0.8) { //проверяем, нужно ли рисовать кривую Безье
        ctx.quadraticCurveTo(touches.x[i], touches.y[i], (touches.x[i] + touches.x[i+1]) * 0.5, (touches.y[i] + touches.y[i+1]) * 0.5);
        continue;
      }

  ctx.lineTo(touches.x[i], touches.y[i]); 
  ctx.lineTo((touches.x[i] + touches.x[i+1]) * 0.5, (touches.y[i] + touches.y[i+1]) * 0.5);
  }
  ctx.lineTo(touches.x[touches.x.length-1], touches.y[touches.y.length-1]);
  ctx.moveTo(touches.x[touches.x.length-1], touches.y[touches.y.length-1]);
  ctx.stroke();  
  ctx.closePath();
}

Демо
Исходный код

P.S. Если у Вас возникли какие-нибудь вопросы — задавайте! Постараюсь ответить.