В этом топике я покажу как создать простой графический редактор для 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();
}