Знакомимся с Fabric.js. Часть 2-я.

Это вторая часть серии статей об открытой Javascript canvas библиотеке Fabric.js, которую мы используем на printio.ru для редактора дизайнов.

В первой части этой серии, мы ознакомились с самыми базовыми аспектами canvas библиотеки Fabric.js. Мы узнали чем может быть полезна Fabric, рассмотрели её объектную модель и иерархию объектов; увидели что существуют как простые фигуры (прямоугольник, треугольник, круг), так и сложные (SVG). Научились выполнять простые операции над этими объектами.

Ну вот, разобрались с азами, давайте приступать к более интересным вещам!

Анимация

Любая уважающая себя canvas библиотека в наше время включает в себя средства работы с анимацией. Fabric — не исключение. Ведь мы имеем мощную объектную модель и гибкие графические возможности. Было бы грех не уметь это приводить в движение.

Вы наверное помните как менять атрибут у объекта. Просто вызываем метод set, передавая соответствующее значение:

rect.set('angle', 45);

Анимировать объект можно по такому же принципу и с такой же лёгкостью. Каждый объект в Fabric имеет метод animate (наследуя от fabric.Object) который... анимирует этот объект.

rect.animate('angle', 45, {
  onChange: canvas.renderAll.bind(canvas)
});

Первый аргумент это атрибут который хотим менять. Второй аргумент — финальное значение этого атрибута. Например, если прямоугольник находится под углом -15°, и мы указываем "45", то угол постепенно изменится с -15° до 45°. Ну а последний аргумент — опциональный объект для более детальных настроек (длительность, вызовы, easing, и т.д.)

animate кстати имеет очень полезную функциональность — поддержку относительных значений. Например, если нужно подвинуть объект на 100px вправо, то сделать это очень просто:

rect.animate('left', '+=100', { onChange: canvas.renderAll.bind(canvas) });

По такому же принципу, для поворота объекта на 5 градусов против часовой стрелки:

rect.animate('angle', '-=5', { onChange: canvas.renderAll.bind(canvas) });

Вы наверняка заметили что мы постоянно указываем вызов "onChange". Разве 3-й аргумент не опциональный? Да, именно так. Дело в том что как раз это вызывание canvas.renderAll на каждый кадр анимации позволяет видеть саму анимацию! Mетод animate всего лишь изменяет значение атрибута в течении указаного времени, и по определённому алгоритму (easing). rect.animate('angle', 45) изменяет значение угла, при этом не перерисовывая экран после каждого изменения. А перерисовка экрана нужна для того чтобы увидеть анимацию.

Ну а почему же animate не перерисовывает экран автоматически? Из-за производительности. Ведь на холсте могут находиться сотни или даже тысячи объектов. Было бы довольно ужасно если каждый из объектов перерисовывал экран при изменении. В таком случае лучше использовать например requestAnimationFrame для постоянной отрисовки холста, не вызывая renderAll для каждого объекта. Однако в большинстве случаев, вы скорее всего будете использовать canvas.renderAll как "onChange" вызов.

Возвращаясь к опциям для анимации, что же именно мы можем менять?

Все эти опции более менее очевидны, кроме наверное easing. Давайте посмотрим поближе.

По умолчанию, animate используют "easeInSine" функцию для смягчения анимации. Если такой вариант не подходит, в Fabric имеется большой набор популярных easing функций (доступных через объект fabric.util.ease). Например, вот так можно подвинуть объект направо, при этом отпружинивая в конце:

rect.animate('left', 500, {
  onChange: canvas.renderAll.bind(canvas),
  duration: 1000,
  easing: fabric.util.ease.easeOutBounce
});

Заметьте что мы используем fabric.util.ease.easeOutBounce как опцию смягчения. Есть и другие популярные функции — easeInCubic, easeOutCubic, easeInElastic, easeOutElastic, easeInBounce, easeOutExpo, и т.д.

Вот в принципе и всё что нужно знать о анимации. Теперь можно с лёгкостью делать интересные вещи — менять угол объекта чтобы сделать его вращающимся; анимировать left/top чтобы его двигать; анимировать width/height для увеличения/уменьшения; анимировать opacity для появления/исчезания; и т.д.

Фильтры изображений

В первой части этой серии, мы узнали как работать с изображениями в Fabric. Как вы наверное помните, для этого используется fabric.Image конструктор, передавая в него <img> елемент. Также есть метод fabric.Image.fromURL, с помощью которого можно создать объект прямо из строки URL. И конечно же эти fabric.Image объекты можно кинуть на холст где они отобразятся как и всё остальное.

Работать с изображениями прикольно, а с фильтрами изображений — ещё веселей!

Fabric уже имеет несколько фильтров, а также позволяет легко определять свои. Некоторые фильтры из Fabric вам наверное знакомы — удаление белого фона, перевод в чёрно-белый, негатив или яркость. А некоторые менее популярны — градиентная прозрачность, сепия, шум.

Так как же применить фильтр к изображению? Каждый fabric.Image объект имеет "filters" атрибут, который просто является массивом фильтров. Каждый элемент в этом массиве — или один из существующих в Fabric или собственный фильтр.

Ну вот, к примеру, сделаем картинку чёрно-белой:

fabric.Image.fromURL('pug.jpg', function(img) {

  // добавляем фильтр
  img.filters.push(new fabric.Image.filters.Grayscale());

  // применяем фильтры и перерисовываем канвас после применения
  img.applyFilters(canvas.renderAll.bind(canvas));

  // добавляем изображения на холст
  canvas.add(img);
});

А вот так можно сделать сепию:

fabric.Image.fromURL('pug.jpg', function(img) {
  img.filters.push(new fabric.Image.filters.Sepia());
  img.applyFilters(canvas.renderAll.bind(canvas));
  canvas.add(img);
});

С атрибутом "filters" можно делать всё тоже что и с обычным массивом — удалить фильтр (с помощью pop, splice, или shift), добавить фильтр (с помощью push, splice, unshift), или даже соединить несколько фильтров. Когда вызывается applyFilters, все фильтры в массиве применяются к картинке по очереди. Вот, например, давайте создадим картинку с увеличеной яркостью и с эффектом сепии:

fabric.Image.fromURL('pug.jpg', function(img) {
  img.filters.push(
    new fabric.Image.filters.Sepia(),
    new fabric.Image.filters.Brightness({ brightness: 100 }));

  img.applyFilters(canvas.renderAll.bind(canvas));
  canvas.add(img);
});

Заметьте, что мы передали { brightness: 100 } объект в Brightness фильтр. Это потому что некоторым фильтрам ничего дополнительного не нужно, а некоторым (например grayscale, invert, sepia) надо указать определённые параметры. Для фильтра яркости, это собственно само значение яркости (0-255). У фильтра шума, это значение шума (0-1000). А у фильтра удаления белого фона ("remove white"), есть порог (threshold) и расстояние (distance).

Ну вот разобрались с фильтрами; пора создать свой!

Образец для создания фильтров будет довольно прост. Нам нужно создать "класс", и написать метод applyTo. Опционально, мы можем дать фильтру toJSON метод (поддержка JSON сериализации), и/или initialize (если фильтр имеет дополнительные параметры).

fabric.Image.filters.Redify = fabric.util.createClass({

  type: 'Redify',

  applyTo: function(canvasEl) {
    var context = canvasEl.getContext('2d'),
        imageData = context.getImageData(0, 0, canvasEl.width, canvasEl.height),
        data = imageData.data;

    for (var i = 0, len = data.length; i < len; i += 4) {
      data[i + 1] = 0;
      data[i + 2] = 0;
    }

    context.putImageData(imageData, 0, 0);
  }
});

fabric.Image.filters.Redify.fromObject = function(object) {
  return new fabric.Image.filters.Redify(object);
};

Не вникаясь сильно в подробности кода, стоит заметить что самое главное происходит в цикле, где мы меняем зелёную (data[i+1]) и голубую (data[i+2]) компоненты каждого пикселя на 0, по сути дела удаляя их. Красная компонента остаётся нетронутой, что и делает всё изображение красным. Как видите, applyTo метод получает в себя canvas елемент, который представляет собой изображение. Имея такой канвас, мы можем пройтись по всем пикселям изображения (getImageData().data) изменяя их, как нам угодно.

Цвета

Независимо от того, с чем вам удобней работать — hex, RGB, или RGBA форматами цвета — Fabric упрощает утомительные операции и переводы из одного формата в другой. Давайте посмотрим на несколько способов определить цвет в Fabric:

new fabric.Color('#f55');
new fabric.Color('#123123');
new fabric.Color('356735');
new fabric.Color('rgb(100,0,100)');
new fabric.Color('rgba(10, 20, 30, 0.5)');

Перевод формата происходит очень просто. toHex() переводит цвет в hex. toRgb() — в RGB, а toRgba() — в RGB с альфа каналом (прозрачностью).

new fabric.Color('#f55').toRgb(); // "rgb(255,85,85)"
new fabric.Color('rgb(100,100,100)').toHex(); // "646464"
new fabric.Color('fff').toHex(); // "FFFFFF"

Кстати можно делать не только перевод. Можно "накладывать" цвета один на другой, или делать из них чёрно-белый вариант.

var redish = new fabric.Color('#f55');
var greenish = new fabric.Color('#5f5');

redish.overlayWith(greenish).toHex(); // "AAAA55"
redish.toGrayscale().toHex(); // "A1A1A1"

Градиенты

Ещё более экспрессивный способ работы с цветами — используя градиенты. Градиенты позволяет плавно смешать один цвет с другим, открывая возможность довольно изумительным эффектам.

Fabric поддерживает их с помощью метода setGradient (setGradientFill до 1.1.0 версии), который присутствует на всех объектах. Вызывание setGradient('fill', { ... }) это почти как выставление значения "fill" у объекта, только вместо цвета используется градиент.

var circle = new fabric.Circle({
  left: 100,
  top: 100,
  radius: 50
});

circle.setGradient('fill', {
  x1: 0,
  y1: -circle.height / 2,
  x2: 0,
  y2: circle.height / 2,
  colorStops: {
    0: '#000',
    1: '#fff'
  }
});

В этом примере, мы создаём круг в точке 100,100, с радиусом в 50px. Потом выставляем ему градиент, идущий по всей высоте объекта, от чёрного к белому.

Как видите, метод получает в себя конфигурационный объект, в котором могут присутствовать 2-е пары координат (x1, y1 и x2, y2), и объект "colorStops". Координаты указывает где градиент начинается и где он заканчивается. colorStops указывают из каких цветов он состоит. Вы можете определить сколько угодно цветов; главное чтобы их позиции находились в интервале от 0 до 1 (например 0, 0.1, 0.3, 0.5, 0.75, 1). 0 представляет начало градиента, 1 — его конец.

Чтобы понять, как мы определили координаты, посмотрим на изображение ниже.

Так как координаты относительны центру объекта, то верхней точкой является -circle.height / 2, а нижней circle.height / 2. Координаты по ширине (x1, x2) определяем точно так же.

Вот пример красно-голубого градиента, идущего слева направо:

circle.setGradient('fill', {
  x1: -circle.width / 2,
  y1: 0,
  x2: circle.width / 2,
  y2: 0,
  colorStops: {
    0: "red",
    1: "blue"
  }
});

А вот 5-ти шаговый градиент-радуга, с цветами занимающими по 20% всей длины:

circle.setGradient('fill', {
  x1: -circle.width / 2,
  y1: 0,
  x2: circle.width / 2,
  y2: 0,
  colorStops: {
    0: "red",
    0.2: "orange",
    0.4: "yellow",
    0.6: "green",
    0.8: "blue",
    1: "purple"
  }
});

А вы можете придумать что-нибудь интересное?

Текст

Что если нужно отобразить не только картинки и векторные формы на холсте, а ещё и текст? Fabric умеет и это! Встречайте fabric.Text.

Перед тем как говорить о тексте, стоит отметить зачем мы вообще предоставляем поддержку работы с текстом. Ведь canvas имеет встроенные методы fillText и strokeText.

Во-первых, для того чтобы можно было работать с текстом как с объектами. Встроеные canvas методы — как обычно — позволяют вывести текст на очень низком уровне. А вот создав объект типа fabric.Text, мы можем работать с ним как и с любым другим объектом на холсте — двигать его, масштабировать, менять атрибуты, и т.д.

Вторая причина — чтобы иметь более богатый функционал чем то что даёт нам canvas. Некоторые вещи которые есть в Fabric но нет в родных методах:

Ну что ж, давайте посмотрим на вездесущий "hello world"?

var text = new fabric.Text('hello world', { left: 100, top: 100 });
canvas.add(text);

Вот и всё! Для показа текста, необходимо всего лишь добавить объект типа fabric.Text на холст, указывая нужную позицию. Первый параметр необходим — это собственно сама строка текста. Второй аргумент — опциональная конфигурация, как обычно; можно указать left, top, fill, opacity, и т.д.

Помимо обычных атрибутов, у текстовых объектов конечно же есть и свои, относящиеся к тексту. Вкратце, об этих атрибутах:

fontFamily

По умолчанию "Times New Roman". Позволяет менять семейство шрифта для текста.

var comicSansText = new fabric.Text("I'm in Comic Sans", {
  fontFamily: 'Comic Sans'
});

fontSize

Контролирует размер текста. Заметьте что в отличие от других объектов в Fabric, мы не можем менять размер текста с помощью width/height. Вместо этого как раз и используется fontSize, и конечно же scaleX/scaleY.

var text40 = new fabric.Text("I'm at fontSize 40", {
  fontSize: 40
});
var text20 = new fabric.Text("I'm at fontSize 20", {
  fontSize: 20
});

fontWeight

Позволяет сделать текст жирнее или тоньше. Точно также как в CSS, можно использовать или слова ("normal", "bold") или номерные значения (100, 200, 400, 600, 800). Важно понимать, что для определённой толщины нужно иметь соответствующий шрифт. Если в шрифте не присутствует "bold" (жирный) вариант, например, то жирный текст может не отобразиться.

var normalText = new fabric.Text("I'm a normal text", {
  fontWeight: 'normal'
});
var boldText = new fabric.Text("I'm a bold text", {
  fontWeight: 'bold'
});

textDecoration

Позволяет добавить тексту перечёркивание, надчёркивание, или подчёркивание. Опять же, эта декларация работает также как в CSS. Однако Fabric умеет даже немного больше, позволяя использовать эти декорации вместе (например, подчёркивание И перечёркивание), просто перечисляя их через пробел.

var underlineText = new fabric.Text("I'm an underlined text", {
  textDecoration: 'underline'
});
var strokeThroughText = new fabric.Text("I'm a stroke-through text", {
  textDecoration: 'line-through'
});
var overlineText = new fabric.Text("I'm an overline text", {
  textDecoration: 'overline'
});

shadow

До версии 1.3.0, этот атрибут назывался "textShadow"

Тень для текста. Состоит из 4-х компонент: цвет, горизонтальный отступ, вертикальный отступ, и размер размытия. Это всё должно быть знакомо если вы до этого работали с тенями в CSS. Меняя эти 4 опции, можно добиться многих интересных эффектов.

var shadowText1 = new fabric.Text("I'm a text with shadow", {
  shadow: 'rgba(0,0,0,0.3) 5px 5px 5px'
});
var shadowText2 = new fabric.Text("And another shadow", {
  shadow: 'rgba(0,0,0,0.2) 0 0 5px'
});
var shadowText3 = new fabric.Text("Lorem ipsum dolor sit", {
  shadow: 'green -5px -5px 3px'
});

fontStyle

Стиль текста. Может быть только один из двух: "normal" или "italic". Опять же, работает так же как и в CSS.

var italicText = new fabric.Text("A very fancy italic text", {
  fontStyle: 'italic',
  fontFamily: 'Delicious'
});
var anotherItalicText = new fabric.Text("another italic text", {
  fontStyle: 'italic',
  fontFamily: 'Hoefler Text'
});

stroke и strokeWidth

Соединяя stroke (цвет наружнего штриха) и strokeWidth (ширину наружнего штриха), можно достичь довольно интересных эффектов. Вот пара примеров:

var textWithStroke = new fabric.Text("Text with a stroke", {
  stroke: '#ff1318',
  strokeWidth: 1
});
var loremIpsumDolor = new fabric.Text("Lorem ipsum dolor", {
  fontFamily: 'Impact',
  stroke: '#c3bfbf',
  strokeWidth: 3
});

Стоит омтетить что "stroke" был назван "strokeStyle" до версии 1.1.6

textAlign

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

Возможные значения: "left", "center", "right", и "justify"

var text = 'this is\na multiline\ntext\naligned right!';
var alignedRightText = new fabric.Text(text, {
  textAlign: 'right'
});

lineHeight

Ещё один атрибут скорее всего знакомый из CSS — lineHeight (высота строки). Позволяет менять расстояние между строк в многострочном тексте. Вот пример текста с lineHeight 3, и второй с lineHeight 1.

var lineHeight3 = new fabric.Text('Lorem ipsum ...', {
  lineHeight: 3
});
var lineHeight1 = new fabric.Text('Lorem ipsum ...', {
  lineHeight: 1
});

textBackgroundColor

И наконец, дать тексту фон можно с помощью textBackgroundColor. Заметьте что фон заполняется только под самим текстом, а не на всю "коробку". Чтобы закрасить весь текстовый объект, можно использовать атрибут "backgroundColor". Также видно что фон зависит от выравнивания текста и lineHeight. Если lineHeight очень большой, фон будет видно только под текстом.

var text = 'this is\na multiline\ntext\nwith\ncustom lineheight\n&background';
var textWithBackground = new fabric.Text(text, {
  textBackgroundColor: 'rgb(0,200,0)'
});

События

События — незаменимый инструмент для создания сложных приложений. Для удобства пользования, и более детальной настройки, Fabric имеет обширную систему событий; начиная от низкоуровневых событий мыши, и вплоть до высокоуровневых событий объектов.

События позволяют нам "поймать" различные моменты когда что-то происходит на холсте. Хотим узнать когда была нажата мышка? Следим за событием "mouse:down". Как насчёт когда объект был добавлен на холст? Для этого есть "object:added". Ну а что насчёт перерисовки холста? Используем "after:render".

API событий очень прост, и похож на то к чему вы скорее всего привыкли в jQuery, Underscore.js, или других популярных JS библиотеках. Есть метод on для инициализации слушателя событий, и есть метод off для его удаления.

Давайте посмотрим на пример:

var canvas = new fabric.Canvas('...');
canvas.on('mouse:down', function(options) {
  console.log(options.e.clientX, options.e.clientY);
});

Мы добавили слушатель события "mouse:down" на canvas объекте, и указали обработчика, который будет записывать координаты где произошло это событие. Таким образом, мы можем видеть где именно произошёл клик на холсте. Обработчик событий получает options объект, с двумя параметрами: e — оригинальное событие, и target — Fabric объект на холсте, если он найден. Первый параметр присутствует всегда, а вот target только если клик произошёл на объекте. Ну и конечно же, target передаётся только разработчикам тех событий, где это имеет смысл. Например, для "mouse:down" но не для "after:render" (так как это событие не "имеет" никаких объектов, а просто обозначает что холст был перерисован).

canvas.on('mouse:down', function(options) {
  if (options.target) {
    console.log('an object was clicked! ', options.target.type);
  }
});

Этот пример выведет "an object was clicked!" если мы нажмём на объект. Также покажется тип этого объекта.

Какие ещё события доступны в Fabric? На уровне мышки, у нас есть "mouse:down", "mouse:move", и "mouse:up". Из общих, есть "after:render". Есть события касающиеся выбора объектов: "before:selection:cleared", "selection:created", "selection:cleared". Ну и конечно же, события объектов: "object:modified", "object:selected", "object:moving", "object:scaling", "object:rotating", "object:added", и "object:removed".

Стоит заметить, что события типа "object:moving" (или "object:scaling") происходят постоянно, во время движения или масштабирования объекта, даже если на один пиксель. В то же время, события типа "object:modified" или "selection:created" происходят только в конце действия (изменение объкта, создание групы объектов, и т.д.).

В предыдущих примерах мы присоединяли слушателя на canvas объект (canvas.on('mouse:down', ...)). Как вы наверное догадываетесь, это означает что события распространяются только на тот холст к которому мы их присоединили. Если у вас несколько холстов на странице, вы можете дать им разные слушатели. События на одном холсте не распространяются на другие холсты.

Для удобства, Fabric позволяет добавлять слушатели прямо на Fabric объекты!

var rect = new fabric.Rect({ width: 100, height: 50, fill: 'green' });
rect.on('selected', function() {
  console.log('selected a rectangle');
});

var circle = new fabric.Circle({ radius: 75, fill: 'blue' });
circle.on('selected', function() {
  console.log('selected a circle');
});

В этом примере, слушатели "присоединяются" прямо к прямоугольнику и кругу. Вместо "object:selected", мы используем событие "selected". По такому же принципу, можно использовать событие "modified" ("object:modified" когда "вешаем" на холст), "rotating" (аналог "object:rotating"), и т.д.

Вы можете ознакомиться с событиями поближе и прямо в реальном времени вот в этой демо.

На этом 2-ая часть подошла к концу. Столько всего нового, но это ещё не всё! В 3-й части мы рассмотрим групы объектов, сериализацию/десериализацию холста и формат JSON, SVG парсер, а также создание подклассов.