Статьи

Создайте редактор изображений с EaselJS, jQuery и файловым API HTML5

Поскольку HTML5 становится все более популярным, все больше основных браузеров начинают поддерживать его API. Сегодня, используя API-интерфейсы Canvas и File, мы можем создать полноценный графический редактор с функциями на уровне некоторых настольных приложений. Для этого мы будем использовать библиотеку EaselJS . Он использует синтаксис, аналогичный AS3, поэтому его будет легко понять как программистам Flash, так и JavaScript.


Давайте посмотрим на конечный результат, к которому мы будем стремиться:




Нажмите, чтобы попробовать демо

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


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


Я начну необычным образом с файлов CSS. Сначала создайте файл style.css :

В первой строке мы меняем шрифт и отключаем контуры вокруг элементов. Далее есть только определения стилей: ul#mainmenu — это элемент главного меню, div#overlay — это заливка под всеми диалогами, а ul#layers — это панель «Слои», которая будет отображаться в правой части холста. Затем мы определяем стиль для кнопок инструментов, и, наконец, у нас есть фрагмент стиля jQuery-UI, потому что эта часть нам понадобится для диалога обрезки слоя.

Затем следует файл print.css который содержит только две строки, чтобы скрыть все, кроме холста, при печати изображения (этот стиль применяется только при печати страницы из-за ее объявления в файле HTML ).

Первая строка скрывает все элементы внутри секции body , а вторая делает видимым только холст (а также выровненный по верхнему левому углу). Это потому, что когда кто-то хочет напечатать изображение, он обычно не хочет печатать интерфейс.


Вы должны иметь общее представление об интерфейсе, взглянув на файлы CSS выше. Теперь создайте файл index.html и введите следующие строки:

Обратите внимание на спецификацию HTML5 типа документа. Нет бесполезных длинных спецификаций DTD; просто слово html .

Вверху мы связываем необходимые библиотеки и файлы JS. Все они включены в исходный код загрузки ; конкретные библиотеки — это EaselJS , jQuery и jQuery UI .

(Для ознакомления с EaselJS ознакомьтесь с данным руководством .)

Далее мы строим всю структуру пользовательского интерфейса. Просто помните: каждый div с классом dialog — это просто диалог для пользователя, чтобы ввести данные, необходимые для выполнения какой-либо операции над изображением. Если вы запустите этот код в своем браузере сейчас, вы заметите несколько 404 ошибок в консоли и то, что меню не будет работать, но мы исправим это, когда создадим файл ui.js


Хорошей практикой является объединение всех функций и переменных, связанных с вашим приложением, в один объект, чтобы предотвратить их переопределение внешними библиотеками или даже вашими собственными сценариями. Наш объект будет выглядеть так:

01
02
03
04
05
06
07
08
09
10
11
12
13
app = {
    stage: null,
    canvas: null,
    layers: [],
    tool: TOOL_SELECT,
    callbacks: {},
    selection: {
        x: -1, y: -1
    },
    renameLayer: 0,
    undoBuffer: [],
    redoBuffer: []
}

(Я начинаю все имена переменных и функций с маленькой буквы, основываясь на условных обозначениях Дугласа Крокфорда для языка программирования JavaScript .)

В app.stage мы будем хранить ссылку на объект Stage для нашего приложения. Если вы что-то кодировали в ActionScript, подумайте об этой стадии как об AS3. Он имеет список отображения, который рисуется в элементе canvas при каждом обновлении. app.canvas является переменной со ссылкой на элемент canvas внутри нашего HTML-документа. Мы будем использовать его для создания сцены и изменения ее размера вместе с окном.

Массив app.layers будет содержать все слои изображения, а app.tool содержит значение фактически выбранного инструмента. В app.callbacks будет содержаться каждый обратный вызов события, который нам нужно будет указать (например, нажав кнопку меню), app.renameLayer содержит номер фактически переименованного слоя, а app.undoBuffer и app.redoBuffer — это массивы для хранения резервной копии app.layers состояние app.layers чтобы заставить работать функции отмены и возврата.

Вам также нужно будет добавить эти четыре строки перед определением app (это просто константы идентификатора инструмента):

1
2
3
4
const
    TOOL_MOVE = 0,
    TOOL_SELECT = 1,
    TOOL_TEXT = 2;

Теперь мы определим методы этого объекта. Сначала добавьте следующие refreshLayers() и sortLayers() :

001
002
003
004
005
006
007
008
009
010
011
012
013
014
+015
016
+017
018
019
020
021
022
023
024
025
026
027
028
029
+030
031
032
033
034
035
036
037
038
039
040
041
042
043
044
045
046
047
048
049
050
051
052
053
054
+055
056
057
058
059
060
061
062
063
064
065
066
067
068
069
070
071
072
073
074
075
076
077
078
079
080
081
082
083
084
085
086
087
088
089
090
091
092
093
094
095
096
097
098
099
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
refreshLayers: function () {
    if ((this.getActiveLayer() == undefined) && (this.layers.length > 0)) this.layers[0].active = true;
    this.stage = new Stage(this.canvas);
    this.stage.regX = -this.canvas.width / 2;
    this.stage.regY = -this.canvas.height / 2;
      
  
    app.layers.toString = function () {
        var ret = [];
        for (var i = 0, layer; layer = this[i]; i++) {
            ret.push(‘{«x»:’ + layer.x + ‘,»y»:’ + layer.y + ‘,»scaleX»:’ + layer.scaleX + ‘,»scaleY»:’ + layer.scaleY + ‘,»skewX»:’ + layer.skewX + ‘,»skewY»:’ + layer.skewY + ‘,»active»:’ + layer.active + ‘,»visible»:’ + layer.visible + ‘,»filters»:{«names»:[‘ + (layer.filters != null ? layer.filters.toString().replace(/(\[|\])/g, ‘»‘): ‘null’) + ‘],»values»:[‘ + JSON.stringify(layer.filters) + ‘]}}’);
        }
        return ‘[‘ + ret.join(‘,’) + ‘]’;
    }
      
    $(‘ul#layers’).html(»);
    for (var i = 0, layer; layer = this.layers[i]; i++) {
        var self = this;
        self.stage.addChild(layer);
        (function(t, n) {
            layer.onClick = function (e) {
                if ((self.tool != TOOL_TEXT) || (!t.text)) return true;
                self.activateLayer(t);
                editText = true;
            }
              
            layer.onPress = function (e1) {
                if (self.tool == TOOL_SELECT) {
                    self.activateLayer(t);
                }
                  
                var offset = {
                    x: tx — e1.stageX,
                    y: ty — e1.stageY
                }
                  
                if (self.tool == TOOL_MOVE) self.addUndo();
                  
                e1.onMouseMove = function (e2) {
                    if (self.tool == TOOL_MOVE) {
                        tx = offset.x + e2.stageX;
                        ty = offset.y + e2.stageY;
                    }
                }
            };
        })(layer, i);
        layer.width = (layer.text != null ? layer.getMeasuredWidth() * layer.scaleX: layer.image.width * layer.scaleX);
        layer.height = (layer.text != null ? layer.getMeasuredLineHeight() * layer.scaleY: layer.image.height * layer.scaleY);
        layer.regX = layer.width / 2;
        layer.regY = layer.height / 2;
        $(‘ul#layers’).prepend(‘<li id=»layer-‘ + i + ‘» class=»‘ + (layer.active ? ‘active’: ») + ‘»><img src=»‘ + (layer.text != undefined ? »: layer.image.src) + ‘»/><h1>’ + ((layer.name != null) && (layer.name != ») ? layer.name: ‘Unnamed layer’) + ‘</h1><span><button class=»button-delete»>Delete</button><button class=»button-hide»>’ + (layer.visible ? ‘Hide’: ‘Show’) + ‘</button><button class=»button-rename»>Rename</button>
    }
    this.stage.update();
    $(‘ul#layers’).sortable({
        stop: function () {
            app.sortLayers();
        }
    });
      
    if (this.layers.length > 0) {
        $(‘#button-layercrop’).attr(‘disabled’, false);
        $(‘#button-layerscale’).attr(‘disabled’, false);
        $(‘#button-layerrotate’).attr(‘disabled’, false);
        $(‘#button-layerskew’).attr(‘disabled’, false);
        $(‘#button-layerflipv’).attr(‘disabled’, false);
        $(‘#button-layerfliph’).attr(‘disabled’, false);
        $(‘#button-imagescale’).attr(‘disabled’, false);
        $(‘#button-imagerotate’).attr(‘disabled’, false);
        $(‘#button-imageskew’).attr(‘disabled’, false);
        $(‘#button-imageflipv’).attr(‘disabled’, false);
        $(‘#button-imagefliph’).attr(‘disabled’, false);
        $(‘#button-filterbrightness’).attr(‘disabled’, false);
        $(‘#button-filtercolorify’).attr(‘disabled’, false);
        $(‘#button-filterdesaturation’).attr(‘disabled’, false);
        $(‘#button-filterblur’).attr(‘disabled’, false);
        $(‘#button-filtergaussianblur’).attr(‘disabled’, false);
        $(‘#button-filteredgedetection’).attr(‘disabled’, false);
        $(‘#button-filteredgeenhance’).attr(‘disabled’, false);
        $(‘#button-filteremboss’).attr(‘disabled’, false);
        $(‘#button-filtersharpen’).attr(‘disabled’, false);
    } else {
        $(‘#button-layercrop’).attr(‘disabled’, true);
        $(‘#button-layerscale’).attr(‘disabled’, true);
        $(‘#button-layerrotate’).attr(‘disabled’, true);
        $(‘#button-layerskew’).attr(‘disabled’, true);
        $(‘#button-layerflipv’).attr(‘disabled’, true);
        $(‘#button-layerfliph’).attr(‘disabled’, true);
        $(‘#button-imagescale’).attr(‘disabled’, true);
        $(‘#button-imagerotate’).attr(‘disabled’, true);
        $(‘#button-imageskew’).attr(‘disabled’, true);
        $(‘#button-imageflipv’).attr(‘disabled’, true);
        $(‘#button-imagefliph’).attr(‘disabled’, true);
        $(‘#button-filterbrightness’).attr(‘disabled’, true);
        $(‘#button-filtercolorify’).attr(‘disabled’, true);
        $(‘#button-filterdesaturation’).attr(‘disabled’, true);
        $(‘#button-filterblur’).attr(‘disabled’, true);
        $(‘#button-filtergaussianblur’).attr(‘disabled’, true);
        $(‘#button-filteredgedetection’).attr(‘disabled’, true);
        $(‘#button-filteredgeenhance’).attr(‘disabled’, true);
        $(‘#button-filteremboss’).attr(‘disabled’, true);
        $(‘#button-filtersharpen’).attr(‘disabled’, true);
    }
},
  
sortLayers: function () {
    var tempLayers = [],
        layersList = $(‘ul#layers li’);
          
    for (var i = 0, layer; layer = $(layersList[i]); i++) {
        if (layer.attr(‘id’) == undefined) break;
        tempLayers[i] = this.layers[layer.attr(‘id’).replace(‘layer-‘, ») * 1];
    }
      
    tempLayers.reverse();
    this.layers = tempLayers;
    this.refreshLayers();
}

Обратите внимание, что внутри объекта вам нужно объявлять переменные и функции с помощью «:» вместо «=».

Метод sortLayers() вызывается, когда пользователь перетаскивает слои на панели «Слои»; refreshLayers() вызывается очень часто, потому что он воссоздает app.stage и app.stage добавляет все слои на сцену, устанавливая их свойства и применяя обратные вызовы событий. Эти обратные вызовы позволяют перемещать слои и редактировать текст на текстовых слоях. Это очень важная функция, поскольку она также добавляет все слои на панель «Слои» в пользовательском интерфейсе и отключает кнопки инструментов в меню (если слоев нет), а также включает их (при наличии хотя бы одного слоя).

Перед refreshLayers() вставьте другой набор вспомогательных функций (не забудьте добавить запятую после последней!):

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
getActiveLayer: function () {
    var ret;
    this.layers.forEach(function(v) {
        if (v.active) ret = v;
    });
    if ((ret == undefined) && (this.layers.length > 0)) return this.layers[0];
    return ret;
},
  
getActiveLayerN: function () {
    for (var i = 0, layer; layer = this.layers[i]; i++) {
        if (layer.active) return i;
    }
},
  
activateLayer: function (layer) {
    this.layers.forEach(function (v) {
        v.active = false;
    });
    if (layer instanceof Bitmap) {
        layer.active = true;
    } else {
        if (this.layers[layer] == undefined) return;
        this.layers[layer].active = true;
    }
    this.refreshLayers();
},

Активный слой — это тот, к которому вы применяете все операции (преобразования, добавления фильтров и т. Д.). Вы можете активировать слой, щелкнув по нему на панели «Слои» или используя инструмент «Выбрать» на холсте.

Как видите, параметр метода activateLayer() может быть либо Bitmap либо числом. Если это Bitmap — объект EaselJS для изображений — тогда для его active свойства установлено значение true , а если это число, то слой в этой позиции в массиве app.layers активируется. getActiveLayer() просто возвращает активный слой, а getActiveLayerN() возвращает позицию активного слоя в массиве app.layers .

Последняя группа методов в этом объекте должна быть вставлена ​​непосредственно после объявления app.redoBuffer и перед теми, которые вы добавили туда ранее:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
addUndo: function () {
    this.undoBuffer.push(this.layers.toString());
    this.redoBuffer = [];
},
  
loadLayers: function (from, to) {
    var json, jsonString = from.pop();
    if (jsonString == undefined) return false;
    to.push(this.layers.toString());
    json = JSON.parse(jsonString);
    for (var i = 0, layer, jsonLayer; ((layer = this.layers[i]) && (jsonLayer = json[i])); i++) {
        for (value in jsonLayer) {
            if (value != ‘filters’) {
                layer[value] = jsonLayer[value];
            } else {
                var hadFilters = (layer.filters != null && layer.filters.length > 0);
                layer.filters = [];
                for (var j = 0; j < jsonLayer.filters.names.length; j++) {
                    if (jsonLayer.filters.names[j] == null) break;
                    layer.filters[j] = new window[jsonLayer.filters.names[j]];
                    for (value2 in jsonLayer.filters.values[0][j]) {
                        layer.filters[j][value2] = jsonLayer.filters.values[0][j][value2];
                    }
                    hadFilters = true;
                }
                if (hadFilters) {
                    if (layer.cacheCanvas) {
                        layer.updateCache();
                    } else {
                        layer.cache(0, 0, layer.width, layer.height);
                    }
                }
            }
        }
    }
    this.refreshLayers();
},
  
undo: function () {
    this.loadLayers(this.undoBuffer, this.redoBuffer);
},
  
redo: function () {
    this.loadLayers(this.redoBuffer, this.undoBuffer);
},

Как вы заметили, читая app.refreshLayers() , метод app.layers toString() переопределяется кодом, который подготавливает строковую версию всех слоев внутри. Конечно, хранить всю информацию о слоях было бы пустой тратой памяти, поэтому резервное копирование выполняется только для тех значений, которые могут быть изменены приложением.

Метод addUndo() фактическое состояние слоев в массив app.undoBuffer и очищает app.redoBuffer — потому что, когда вы делаете действие, которое можно отменить, вы не можете восстановить все, что было отменено до этого действия. loadLayers() принимает два аргумента (массив, из которого мы должны app.layers состояние app.layers и массив, в который мы должны app.layers фактическое состояние этой переменной), и выполняет анализ резервных app.layers .

Как показывает пример фильтра EaselJS:

«… фильтры отображаются только при кэшировании экранного объекта …»

(Из примеров EaselJS: фильтры .)

Это означает, что вам нужно вызвать метод cache() Bitmap чтобы применить фильтр. Кэширование выполняется для повышения производительности — фильтр применяется только один раз, и отрисовывается только отфильтрованное растровое изображение. EaselJS кеширует контент очень умным способом — он просто копирует его в другой элемент canvas, который не добавляется в документ (он скрыт). Я упоминаю об этом, потому что в конце метода loadLayers() есть блок if который проверяет, есть ли какие-либо фильтры, которые должны быть обновлены на этом слое — и, если они есть, обновляет элемент cache или cache


Инициализация всего приложения проста; просто вставьте это после объявления app :

01
02
03
04
05
06
07
08
09
10
11
12
tick = function () {
    app.stage.update();
}
  
$(document).ready(function () {
    app.canvas = $(‘canvas’)[0];
      
    document.onselectstart = function () { return false;
      
    Ticker.setFPS(30);
    Ticker.addListener(window);
});

Ticker — это встроенный в EaselJS таймер, который вызывает функцию tick() слушателя, чтобы поддерживать стабильный ранее установленный FPS Таким образом, мы можем автоматически вызывать app.stage.update() для перерисовки app.stage.update() области.

В начале (сразу после загрузки документа) мы назначаем первый элемент canvas на странице, который функция $ ( jQuery ) находит для app.canvas , затем мы отключаем выбор чего-либо в документе (потому что в противном случае при перетаскивании мыши через холст есть эффект, как будто вы выбираете текст).

Мы устанавливаем скорость тикера в 30 (вам нужно всего 24 кадра в секунду, чтобы заставить человека думать, что он видит движение) и устанавливаем окно в качестве слушателя тикера.


Теперь пришло время воплотить в жизнь наше меню и весь пользовательский интерфейс. Файл ui.js будет почти полностью состоять из функций jQuery, поэтому его действительно легко понять. Начнем с вспомогательных функций:

01
02
03
04
05
06
07
08
09
10
11
12
importFile = false;
  
hideDialog = function (dialog) {
    $(dialog).hide();
    if ($(‘.dialog:visible’).length == 0) $(‘#overlay’).hide();
    editText = false;
}
  
showDialog = function (dialog) {
    $(‘#overlay’).show();
    $(dialog).show();
}

Переменная importFile сообщит нам, открываем ли мы файл или импортируем его.

Имена функций showDialog() и hideDialog() говорят сами за себя — хотя одна интересная вещь в функции hideDialog() — это то, как она проверяет, все ли диалоги скрыты с помощью псевдокласса jQuery ‘: visible’, только чтобы затем скрыть наложение. В конце концов, это оказалось бесполезным, потому что нет ситуации, когда на экране более одного диалога, но я оставил его для вашего будущего использования; Может быть, это пригодится.


Теперь мы должны что-то сделать, когда пользователь изменит размер окна браузера. Это когда событие изменения размера window вступает в игру. Он запускается каждый раз, когда пользователь изменяет размер окна браузера:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
$(window).resize(function () {
    $(‘.dialog’).each(function () {
        $(this).css({ left: window.innerWidth / 2 — $(this).outerWidth() / 2 + ‘px’, top: window.innerHeight / 2 — $(this).outerHeight() / 2 + ‘px’ });
    });
      
    $(‘canvas’).attr(‘height’, $(window).height() — 37).attr(‘width’, $(window).width() — 232);
    $(‘ul#mainmenu’).width($(window).width() — 4);
    $(‘ul#layers’).css({ height: $(window).height() — 37 });
      
    app.refreshLayers();
      
    if ($(‘#cropoverlay’).css(‘display’) == ‘block’) {
        $(‘#cropoverlay’).css({
            left: Math.ceil(app.canvas.width / 2 — app.getActiveLayer().x — app.getActiveLayer().regX — 1) + ‘px’,
            top: Math.ceil(app.canvas.height / 2 + app.getActiveLayer().y — app.getActiveLayer().regY + 38) + ‘px’
        });
    }
});

Сначала мы центрируем все диалоги с помощью функции each() jQuery. Это вызывает обратный вызов для каждого элемента, которому соответствует селектор в функции $ .

Затем мы должны установить ширину и высоту холста — но не в CSS, потому что это растянет изображения внутри холста, а мы этого не хотим. Высота меню составляет 37 пикселей, поэтому мы устанавливаем высоту холста равной высоте окна минус 37 пикселей. То же самое для ширины, но на этот раз мы должны вычесть ширину панели слоев, которая составляет 232 пикселей. Мы также изменяем размеры меню и панели «Слои», чтобы соответствовать размеру окна (здесь мы можем использовать CSS).

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


Кнопки меню должны быть привязаны к обратным app.callbacks , указанным в app.callbacks , а также нам нужно привязать событие keydown для входов и click кнопки диалога. Последнее предложение может показаться сложным, но когда вы увидите код, оно станет понятным:

001
002
003
004
005
006
007
008
009
010
011
012
013
014
+015
016
+017
018
019
020
021
022
023
024
025
026
027
028
029
+030
031
032
033
034
035
036
037
038
039
040
041
042
043
044
045
046
047
048
049
050
051
052
053
054
+055
056
057
058
059
060
061
062
063
064
065
066
067
068
069
070
071
072
073
074
075
076
077
078
079
080
081
082
083
084
085
086
087
088
089
090
091
092
093
094
095
096
097
098
099
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
$(document).ready(function () {
    $(«ul#mainmenu li button»).click(function () {
        $(this).focus();
        $(this).parent().find(«ul.submenu:visible»).slideUp(‘fast’).show();
        $(this).parent().find(«ul.submenu:hidden»).slideDown(‘fast’).show();
    });
      
    $(«ul#mainmenu li button»).blur(function () {
        $(this).parent().find(«ul.submenu:visible»).delay(100).slideUp(‘fast’).show();
    });
  
    $(‘#button-openfile’).hover(
        function () { $(this).addClass(‘hover’);
        function () { $(this).removeClass(‘hover’);
    );
  
    $(‘#button-importfile’).hover(
        function () { $(this).addClass(‘hover’);
        function () { $(this).removeClass(‘hover’);
    );
  
    $(‘#button-openurl’).click(function () {
        importFile = false;
        showDialog(‘#dialog-openurl’);
        $(‘#dialog-openurl input’).val(»).attr(‘disabled’, false).focus();
    });
  
    $(‘#button-importurl’).click(function () {
        importFile = true;
        showDialog(‘#dialog-openurl’);
        $(‘#dialog-openurl input’).val(»).attr(‘disabled’, false).focus();
    });
      
    $(‘#button-undo’).click(function () { app.undo(); });
    $(‘#button-redo’).click(function () { app.redo(); });
      
    $(‘#button-layerscale’).click(function () {
        affectImage = false;
        showDialog(‘#dialog-scale’);
        $(‘#dialog-scale input.input-scaleX’).val(‘100’);
        $(‘#dialog-scale input.input-scaleY’).val(‘100’);
    });
      
    $(‘#button-layerskew’).click(function () {
        affectImage = false;
        showDialog(‘#dialog-skew’);
        $(‘#dialog-skew input.input-scaleX’).val(‘100’);
        $(‘#dialog-skew input.input-scaleY’).val(‘100’);
    });
      
    $(‘#button-layerrotate’).click(function () {
        affectImage = false;
        showDialog(‘#dialog-rotate’);
        $(‘#dialog-rotate input’).val(‘0’);
    });
      
    $(‘#button-layercrop’).click(function () {
        affectImage = false;
        app.sortLayers();
        app.refreshLayers();
        var layer = app.getActiveLayer();
        $(‘#overlay’).show();
        $(‘#cropoverlay’).css({
            left: Math.ceil(app.canvas.width / 2 + layer.x — layer.regX — 1) + ‘px’,
            top: Math.ceil(app.canvas.height / 2 + layer.y — layer.regY + 38) + ‘px’,
            width: (layer.text != null ? layer.getMeasuredWidth() * layer.scaleX: layer.image.width * layer.scaleX) + 2 + ‘px’,
            height: (layer.text != null ? layer.getMeasuredLineHeight() * layer.scaleY: layer.image.height * layer.scaleY) + 2 + ‘px’
        }).show();
    });
      
    $(‘#button-layerflipv’).click(app.callbacks.layerFlipV);
    $(‘#button-layerfliph’).click(app.callbacks.layerFlipH);
      
    $(‘#button-imagescale’).click(function () {
        affectImage = true;
        showDialog(‘#dialog-scale’);
        $(‘#dialog-scale input.input-scaleX’).val(‘100’);
        $(‘#dialog-scale input.input-scaleY’).val(‘100’);
    });
      
    $(‘#button-imageskew’).click(function () {
        affectImage = true;
        showDialog(‘#dialog-skew’);
        $(‘#dialog-skew input.input-scaleX’).val(‘100’);
        $(‘#dialog-skew input.input-scaleY’).val(‘100’);
    });
      
    $(‘#button-imagerotate’).click(function () {
        affectImage = true;
        showDialog(‘#dialog-rotate’);
        $(‘#dialog-rotate input’).val(‘0’);
    });
      
    $(‘#button-imageskew’).click(function () {
        affectImage = true;
        showDialog(‘#dialog-skew’);
        $(‘#dialog-skew input.input-skewX’).val(‘0’);
        $(‘#dialog-skew input.input-skewY’).val(‘0’);
    });
      
    $(‘#button-filterbrightness’).click(function () {
        showDialog(‘#dialog-filterbrightness’);
        $(‘#dialog-filterbrightness input’).val(‘100’);
    });
      
    $(‘#button-filtercolorify’).click(function () {
        showDialog(‘#dialog-filtercolorify’);
        $(‘#dialog-filtercolorify input’).val(‘0’);
    });
      
    $(‘#button-filterblur’).click(function () {
        showDialog(‘#dialog-filterblur’);
        $(‘#dialog-filterblur input’).val(‘1’);
    });
      
    $(‘#button-filtergaussianblur’).click(function () {
        showDialog(‘#dialog-filtergaussianblur’);
        $(‘#dialog-filtergaussianblur input.7’).attr(‘checked’, true);
    });
      
    $(‘#button-executescript’).click(function () {
        showDialog(‘#dialog-executescript’);
        $(‘#dialog-executescript textarea’).val(»);
    });
      
    $(‘#button-select’).click(function () {
        app.tool = TOOL_SELECT;
        $(‘#mainmenu button’).removeClass(‘active’);
        $(this).addClass(‘active’);
    });
      
    $(‘#button-move’).click(function () {
        app.tool = TOOL_MOVE;
        $(‘#mainmenu button’).removeClass(‘active’);
        $(this).addClass(‘active’);
    });
      
    $(‘#button-text’).click(function () {
        app.tool = TOOL_TEXT;
        $(‘#mainmenu button’).removeClass(‘active’);
        $(this).addClass(‘active’);
    });
      
    $(‘#button-imageflipv’).click(app.callbacks.imageFlipV);
    $(‘#button-imagefliph’).click(app.callbacks.imageFlipH);
      
    $(‘#dialog-openurl input’).keydown(app.callbacks.openURL);
    $(‘#dialog-openurl button.button-ok’).click(app.callbacks.openURL);
    $('#dialog-scale input').keydown(app.callbacks.numberOnly).keydown(app.callbacks.layerScale);
    $('#dialog-scale button.button-ok').click(app.callbacks.layerScale);
    $('#button-openfile input').change(app.callbacks.openFile);
    $('#button-importfile input').change(app.callbacks.importFile);
    $('#dialog-tooltext button.button-ok').click(app.callbacks.toolText);
    $('#dialog-tooltext input').keydown(app.callbacks.toolText);
    $('#dialog-layerrename button.button-ok').click(app.callbacks.layerRename);
    $('#dialog-layerrename input').keydown(app.callbacks.layerRename);
    $('#dialog-rotate button.button-ok').click(app.callbacks.layerRotate);
    $('#dialog-rotate input').keydown(app.callbacks.numberOnly).keydown(app.callbacks.layerRotate);
    $('#dialog-skew button.button-ok').click(app.callbacks.layerSkew);
    $('#dialog-skew input').keydown(app.callbacks.numberOnly).keydown(app.callbacks.layerSkew);
    $('#cropoverlay button.button-ok').click(app.callbacks.layerCrop);
    $('#button-filterdesaturation').click(app.callbacks.filterDesaturation);
    $('#button-filteredgedetection').click(app.callbacks.filterEdgeDetection);
    $('#button-filteredgeenhance').click(app.callbacks.filterEdgeEnhance);
    $('#button-filteremboss').click(app.callbacks.filterEmboss);
    $('#button-filtersharpen').click(app.callbacks.filterSharpen);
    $('#dialog-filterbrightness button.button-ok').click(app.callbacks.filterBrightness);
    $('#dialog-filterbrightness input').keydown(app.callbacks.numberOnly).keydown(app.callbacks.filterBrightness);
    $('#dialog-filtergaussianblur button.button-ok').click(app.callbacks.filterGaussianBlur);
    $('#dialog-filtergaussianblur input').keydown(app.callbacks.numberOnly).keydown(app.callbacks.filterGaussianBlur);
    $('#dialog-filterblur button.button-ok').click(app.callbacks.filterBlur);
    $('#dialog-filterblur input').keydown(app.callbacks.numberOnly).keydown(app.callbacks.filterBlur);
    $('#dialog-filtercolorify button.button-ok').click(app.callbacks.filterColorify);
    $('#dialog-filtercolorify input').keydown(app.callbacks.numberOnly).keydown(app.callbacks.filterColorify);
    $('#dialog-executescript button.button-ok').click(app.callbacks.scriptExecute);
    $('#button-save').click(app.callbacks.saveFile);
    $('#button-print').click(app.callbacks.printFile);
      
    $('#dialog-tooltext input.input-color').keyup(function (e) {
        $(this).css({ backgroundColor: $(this).val() });
    });
      
    $('ul#layers li').live('click', function () {
        app.activateLayer($(this).attr('id').replace('layer-', '') * 1);
    });
      
    $('ul#layers li button.button-delete').live('click', function () {
        app.layers.splice($(this).parent().parent().attr('id').replace('layer-', '') * 1, 1);
        this.undoBuffer = [];
        this.redoBuffer = [];
        app.refreshLayers();
    });
      
    $('ul#layers li button.button-hide').live('click', function () {
        if ($(this).text() == 'Hide') {
            app.layers[$(this).parent().parent().attr('id').replace('layer-', '') * 1].visible = false;
        } else {
            app.layers[$(this).parent().parent().attr('id').replace('layer-', '') * 1].visible = true;
        }
        app.refreshLayers();
    });
      
    $('ul#layers li button.button-rename').live('click', function () {
        $('#dialog-layerrename').show();
        $('#overlay').show();
        $('#dialog-layerrename input').val('');
        app.renameLayer = $(this).parent().parent().attr('id').replace('layer-', '') * 1;
    });
      
    $(document).keydown(function (e) {
        if (e.keyCode == 27) {
            hideDialog('.dialog');
        }
    });
      
    $('.dialog button.button-cancel').each(function () {
        $(this).click(function () {
            hideDialog($(this).parent());
        });
    });
      
    $('canvas').click(app.callbacks.toolText);
      
    $('#cropoverlay').draggable().resizable({
        handles: 'se',
        resize: function (e, ui) {
            $('#cropoverlay').css({ left: ui.position.left + 'px', top: ui.position.top + 'px' });
        },
        stop: function (e, ui) {
            $('#cropoverlay').css({ left: ui.position.left + 'px', top: ui.position.top + 'px' });
        }
    });
      
    $(window).resize();
});

Помните еще одну вещь: когда вы что-то делаете с jQuery и любыми элементами HTML, делайте это внутри document.readyобратного вызова, потому что только тогда вы можете быть уверены, что все элементы, которые вы используете, уже отрисованы.

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

Далее вы должны взглянуть на live()функцию, которую мы используем для привязки событий щелчка к кнопкам слоя (на панели «Слой») — live()функция добавляет обратный вызов для каждого элемента, который будет соответствовать этому селектору в будущем , что делает его очень полезным, так как мы генерируем новый список слоев на каждый app.refreshLayers()звонок.

Последняя функция здесь, $(window).resize()которая вручную запускает resizeсобытие окна. Вот почему важно связывать скрипты по порядку, потому что, если они ui.jsбыли добавлены в HTML раньше main.js, слои будут обновляться до определения функции, что иногда может привести к неожиданным результатам, что еще больше затрудняет поиск ошибки.

Если вы запустите приложение сейчас, вы увидите хорошо работающее меню и правильное изменение размера пользовательского интерфейса, но все равно ни одна кнопка не сделает ничего, кроме выдачи ошибок в консоль при нажатии на нее.


Теперь мы будем использовать другой API из спецификации HTML5: File API. Это позволяет нам открывать файлы с компьютера пользователя, но только тогда, когда он выбирает их в поле ввода файлов ОС (чтобы веб-приложения не могли украсть ваши личные данные).

Обратите внимание, что если вы будете запускать это приложение на локальном компьютере, вам необходимо настроить локальный сервер или добавить --allow-file-access-from-filesпараметр при запуске Chrome, поскольку открытие файлов из локальных веб-страниц по умолчанию отключено.

В file.jsфайл мы также поместим функции для сохранения и печати изображения, поэтому давайте начнем с этих четырех помощников:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
openFile = function (url, first) {
    var img = new Image();
          
    img.onload = function () {
        var n = (first ? 0: app.layers.length);
        if (first) app.layers = [];
        app.layers[n] = new Bitmap(img);
        app.layers[n].x = 0;
        app.layers[n].y = 0;
        app.activateLayer(n);
    }
    img.src = url;
      
    this.undoBuffer = [];
    this.redoBuffer = [];
}
  
openURL = function (self, url) {
    $(self).attr('disabled', true);
    openFile(url, !importFile);
    hideDialog('#dialog-openurl');
}
  
saveFile = function () {
    window.open(app.stage.toDataURL());
}
  
printFile = function () {
    window.print()
}

openFileФункция будет использоваться , чтобы открыть изображение и добавить его к слоям. Если мы выберем «Открыть файл» из меню, то старое содержимое будет стерто, а «Импорт файла» добавит новые слои к изображению. (В функции, если первый параметр, trueто мы открываем файл, в противном случае мы его импортируем).

openURLоткрывает файл, используя ту же функцию, но из внешнего источника (и, насколько мне известно, Chrome отключает доступ к данным междоменных исходных пикселей, что делает эти изображения довольно бесполезными). Поскольку мы не можем сохранить файл на диске пользователя, мы просто открываем другое окно, содержащее только изображение, представляющее фактическую сцену; пользователь может затем щелкнуть правой кнопкой мыши, чтобы сохранить изображение.

Печать достигается по телефону window.print() . Конечно, мы не можем печатать что-либо, не сообщая об этом пользователю, поэтому эта функция откроет диалоговое окно печати по умолчанию, где пользователь может выбрать параметры печати.

Теперь мы определим несколько обратных вызовов для кнопок внутри меню:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
app.callbacks.openFile = function (e) {
    var file = e.target.files[0],
        self = this;
      
    if (!file.type.match('image.*')) return false;
  
    var reader = new FileReader();
    reader.onload = function(e) {
        openFile(e.target.result, true);
    };
  
    reader.readAsDataURL(file);
};
  
app.callbacks.openURL = function (e) {
    switch (e.type) {
        case "click":
            openURL($('#dialog-openurl input'), $('#dialog-openurl input').val());
            break;
        case "keydown":
            if (e.keyCode == 13) openURL(this, $(this).val());
            break;
    }
}
  
app.callbacks.importFile = function (e) {
    for (var i = 0, file; file = e.target.files[i]; i++) {
        if (!file.type.match('image.*')) continue;
  
        var reader = new FileReader();
        reader.onload = function(e) {
            openFile(e.target.result, false);
        };
  
        reader.readAsDataURL(file);
    }
};
  
app.callbacks.saveFile = function () {
    saveFile();
}
  
app.callbacks.printFile = function () {
    printFile();
}

Как видите, код очень короткий, но очень мощный. В app.callbacks.openFileобратном вызове first ( ) мы проверяем, является ли открытый файл изображением, и останавливаем его, если это не так. Затем мы создаем новый FileReader, устанавливаем его onloadобратный вызов, чтобы открыть файл, и вызываемreadAsDataURL(file) метод, который загружает файл и выводит результат в виде URL-адреса данных для чтения.

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

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


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

Мы определим инструмент Text в tools.jsфайле. Добавьте следующие строки:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
editText = false;
  
toolText = function (text, font, color, size, x, y) {
    var n = (editText ? app.getActiveLayerN(): app.layers.length);
    app.layers[n] = new Text(text, size + ' ' + font, color);
    app.layers[n].x = x - app.canvas.width / 2;
    app.layers[n].y = y - app.canvas.height / 2;
    app.layers[n].name = text;
    app.activateLayer(n);
    hideDialog('#dialog-tooltext');
      
    this.undoBuffer = [];
    this.redoBuffer = [];
}
  
app.callbacks.toolText = function (e) {
    switch (e.type) {
        case "click":
            if (e.target instanceof HTMLButtonElement) {
                toolText($('#dialog-tooltext input.input-text').val(), $('#dialog-tooltext select').val(), $('#dialog-tooltext input.input-color').val(), $('#dialog-tooltext input.input-size').val(), (editText ? app.getActiveLayer().x: app.selection.x), (editText ? app.getActiveLayer().y: app.selection.y));
            } else {
                if (app.tool != TOOL_TEXT) return true;
                $('#dialog-tooltext').show();
                $('#overlay').show();
                app.selection.x = e.offsetX;
                app.selection.y = e.offsetY;
                $('#dialog-tooltext input.input-text').val((editText ? app.getActiveLayer().text: ''));
                $('#dialog-tooltext input.input-size').val((editText ? app.getActiveLayer().font.split(' ')[0]: '12px'));
                $('#dialog-tooltext select').val((editText ? app.getActiveLayer().font.split(' ')[1]: 'Calibri'));
                $('#dialog-tooltext input.input-color').val((editText ? app.getActiveLayer().color: 'black'));
                $('#dialog-tooltext input.input-color').css({ backgroundColor: $('#dialog-tooltext input.input-color').val() });
            }
            break;
        case "keydown":
            if (e.keyCode == 13) toolText($('#dialog-tooltext input.input-text').val(), $('#dialog-tooltext select').val(), $('#dialog-tooltext input.input-color').val(), $('#dialog-tooltext input.input-size').val(), (editText ? app.getActiveLayer().x: app.selection.x), (editText ? app.getActiveLayer().y: app.selection.y));
            break;
    }
}

editTextПеременный , trueкогда мы изменяем свойство существующего текстового слоя вместо создания нового.

Первая функция, как обычно, вспомогательная функция. Он проверяет, редактируем ли мы существующий текстовый слой или добавляем новый, затем создает новый Textобъект и добавляет его в прикладные слои. Поскольку все объекты в EaselJS расширяют базу DisplayObject, мы можем использовать оба Textи Bitmapодинаково; отличаются только их свойства.

Вторая функция — обратный вызов. Первое, что нам нужно сделать, это проверить, какой тип события мы получаем (потому что мы используем только один обратный вызов как для обработки нажатия кнопки, так и нажатия клавиши ввода внутри ввода). Тогда мы просто вызываем предыдущего помощника.


Простые преобразования слоев действительно просты с EaselJS. Все, что я покажу здесь, можно сделать, просто изменив свойства слоя ( Bitmapили Text).

Я начну с объяснения точки регистрации. В regXи regYсвойства определяют точку , из которой рассчитываются поворот и положение — это как ручка. В main.jsфайле мы устанавливаем эту точку в центр изображения, чтобы упростить преобразование слоев. Все функции преобразования слоя будут помещены в layer.jsфайл.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
affectImage = false;
  
layerScale = function (x, y) {
    app.addUndo();
    if (affectImage) return imageScale(x, y);
    app.getActiveLayer().scaleX *= x / 100;
    app.getActiveLayer().scaleY *= y / 100;
    hideDialog('#dialog-scale');
}
  
layerRotate = function (deg) {
    app.addUndo();
    if (affectImage) return imageRotate(deg);
    app.getActiveLayer().rotation += deg;
    hideDialog('#dialog-rotate');
}
  
layerSkew = function (degx, degy) {
    app.addUndo();
    if (affectImage) return imageSkew(degx, degy);
    app.getActiveLayer().skewX += degx;
    app.getActiveLayer().skewY += degy;
    hideDialog('#dialog-skew');
}
  
layerFlipH = function () {
    app.addUndo();
    app.getActiveLayer().scaleX = -app.getActiveLayer().scaleX;
}
  
layerFlipV = function () {
    app.addUndo();
    app.getActiveLayer().scaleY = -app.getActiveLayer().scaleY;
}

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

Первое: нам нужно вызвать addUndo()функцию, прежде чем мы начнем что-либо менять. Почему? Потому что мы хотим вернуться в состояние до того, как какая-то операция будет выполнена, когда мы нажмем кнопку отмены.

Также обратите внимание на affectImageпеременную. Мы установим его, trueкогда мы хотим повлиять на все изображение; почти в каждой функции есть ifоператор, проверяющий, влияем ли мы на все изображение, и (если так) возвращающий результат соответствующей image*()функции.

Теперь поместите код обратного вызова в файл:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
app.callbacks.numberOnly = function (e) {
    if ((e.shiftKey) || ([8, 13, 37, 38, 39, 40, 46, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 190, 189].indexOf(e.keyCode) < 0)) return false;
}
  
app.callbacks.layerRename = function (e) {
    switch (e.type) {
        case "click":
            app.layers[app.renameLayer].name = $('#dialog-layerrename input').val();
            app.refreshLayers();
            hideDialog('#dialog-layerrename');
            break;
        case "keydown":
            if (e.keyCode == 13) {
                app.layers[app.renameLayer].name = $('#dialog-layerrename input').val();
                app.refreshLayers();
                hideDialog('#dialog-layerrename');
            }
            break;
    }
}
  
app.callbacks.layerScale = function (e) {
    switch (e.type) {
        case "click":
            layerScale($('#dialog-scale input.input-scaleX').val() * 1, $('#dialog-scale input.input-scaleY').val() * 1);
            break;
        case "keydown":
            if (e.keyCode == 13) layerScale($('#dialog-scale input.input-scaleX').val() * 1, $('#dialog-scale input.input-scaleY').val() * 1);
            break;
    }
}
  
app.callbacks.layerRotate = function (e) {
    switch (e.type) {
        case "click":
            layerRotate($('#dialog-rotate input').val() * 1);
            break;
        case "keydown":
            if (e.keyCode == 13) layerRotate($(this).val() * 1);
            break;
    }
      
}
  
app.callbacks.layerSkew = function (e) {
    switch (e.type) {
        case "click":
            layerSkew($('#dialog-skew input.input-skewX').val() / 100, $('#dialog-skew input.input-skewY').val() / 100);
            break;
        case "keydown":
            if (e.keyCode == 13) layerSkew($('#dialog-skew input.input-skewX').val() / 100, $('#dialog-skew input.input-skewY').val() / 100);
            break;
    }
}
  
}
}
  
app.callbacks.layerCrop = function () {
    var layer = app.getActiveLayer();
    layer.cache(
        Math.floor(app.canvas.width / 2 - $('#cropoverlay').position().left - layer.regX + layer.x - 1),
        Math.floor(app.canvas.height / 2 - $('#cropoverlay').position().top - layer.regY + layer.y + 38),
        $('#cropoverlay').width(),
        $('#cropoverlay').height()
    );
    $(this).parent().find('.button-cancel').click();
}

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

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


Давайте начнем с добавления этого исходного кода; Я объясню это позже. Поместите код ниже в image.jsфайл:

01
02
03
04
05
06
07
08
09
10
imageScale = function (x, y) {
    for (var i = 0, layer; layer = app.layers[i]; i++) {
        layer.scaleX *= x / 100;
        layer.scaleY *= y / 100;
        layer.x *= x / 100;
        layer.y *= y / 100;
    }
    hideDialog('#dialog-scale');
    affectImage = false;
}

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


Вращение будет немного сложнее, чем масштабирование. Но сначала это код; поместите это также в image.jsфайл:

01
02
03
04
05
06
07
08
09
10
11
12
imageRotate = function (deg) {
    for (var i = 0, layer; layer = app.layers[i]; i++) {
        layer.rotation += deg;
        var rad = deg * Math.PI / 180,
            x = (layer.x * Math.cos(rad)) - (layer.y * Math.sin(rad)),
            y = (layer.x * Math.sin(rad)) + (layer.y * Math.cos(rad));
        layer.x = x;
        layer.y = y;
    }
    hideDialog('#dialog-rotate');
    affectImage = false;
}

Мы, конечно, добавляем ротацию слоев, но выделенный мною код является новым. Он основан на уравнениях вращения точки в декартовой системе координат:

Где Φ — угол Таким образом, выделенный код — это всего лишь перевод приведенных выше уравнений в код JavaScript (плюс преобразование из градусов в радианы, потому что тригонометрические функции в Mathбиблиотеке принимают радианы в качестве параметров, а один радиан равен точно pi / 180 градусам).


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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
imageSkew = function (degx, degy) {
    for (var i = 0, layer; layer = app.layers[i]; i++) {
        layer.skewX += degx;
        layer.skewY += degy;
        var radx = degx * Math.PI / 180,
            rady = degy * Math.PI / 180,
            x = (layer.x * Math.cos(radx)) - (layer.y * Math.sin(radx)),
            y = (layer.x * Math.sin(rady)) + (layer.y * Math.cos(rady));
        layer.x = x;
        layer.y = y;
    }
    hideDialog('#dialog-skew');
    affectImage = false;
}

Вы видите разницу? Мы просто используем radxдля позиции х и radyу позиции. Это делает изображение искаженным должным образом (я должен сказать, что это дает довольно хороший эффект).


Это модификация imageScale()функции. Он не последний, потому что это самая сложная из функций преобразования изображений, просто потому, что он находится на последней позиции в меню. Код:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
imageFlipH = function () {
    app.addUndo();
    for (var i = 0, layer; layer = app.layers[i]; i++) {
        layer.scaleX = -layer.scaleX;
        layer.x = -layer.x;
    }
    affectImage = false;
}
  
imageFlipV = function () {
    app.addUndo();
    for (var i = 0, layer; layer = app.layers[i]; i++) {
        layer.scaleY = -layer.scaleY;
        layer.y = -layer.y;
    }
    affectImage = false;
}
  
app.callbacks.imageFlipV = function () {
    imageFlipV();
}
  
app.callbacks.imageFlipH = function () {
    imageFlipH();
}

Конечно, мы можем игнорировать обратные вызовы — мы уже знаем, что они делают. Мы должны сосредоточиться на первых двух функциях. Они просто перебирают слои, устанавливая их scaleXили scaleYпротивоположное значение — это то, что мы знаем как отражение: просто отрицательный масштаб. Кроме того , xили yдолжно быть отменено , чтобы сделать изображение действительно выглядеть переворачивается.

Это было последнее из преобразований изображения. Теперь мы собираемся сделать что-то более продвинутое — фильтры!


Я называю их простыми, потому что мы используем фильтры, встроенные в EaselJS: ColorFilterи ColorMatrixFilter. Они изменяют изображение попиксельно, поэтому с большими изображениями и сложными фильтрами вы можете на некоторое время задержать браузер или даже полностью остановиться.

Прежде чем применять их, я объясню, что делает каждый фильтр.

ColorFilter принимает восемь параметров при создании:

1
new ColorFilter(redMultiplier, greenMultiplier, blueMultiplier, alphaMultiplier, redOffset, greenOffset, blueOffset, alphaOffset);

Когда фильтр применяется, он разбивает изображение на четыре канала (красный, зеленый, синий и альфа) и для каждого канала умножает каждое значение на соответствующий множитель и добавляет соответствующее смещение. (На самом деле, изображение на самом деле не разделено; это удобная метафора.)

ColorMatrixFilter принимает только один параметр при создании:

1
new ColorMatrixFilter(matrix);

Матрица имеет следующий формат:

Когда этот фильтр применяется, он также (в переносном смысле) разбивает изображение на каналы, а затем умножает каждое значение друг на друга. Например, уравнение для значения пикселя в красном канале после прохождения через фильтр:

1
newRed = (red * rr) + (green * rg) + (blue * rb) + (alpha * ra) + ro;

То же самое относится и к зеленому, синему и альфа, только с разными переменными из матрицы ( gr,gg,gb,gaдля зеленого и т. Д.). Этот фильтр немного более продвинутый, чем ColorFilterкаждый, потому что каждый цвет зависит от других цветов пикселя.

Для получения более подробной информации см. Этот учебник .


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

Поместите этот код в начало filters.jsфайла:

01
02
03
04
05
06
07
08
09
10
11
12
applyFilter = function (filter) {
    app.addUndo();
    var layer = app.getActiveLayer();
      
    layer.filters = (layer.filters ? layer.filters: []);
    layer.filters.push(filter);
    if (layer.cacheCanvas) {
        layer.updateCache();
    } else {
        layer.cache(0, 0, layer.width, layer.height);
    }
}

Он делает всю работу за нас: захватывает активный слой; если нет фильтров, то создает массив фильтров; и добавляет фильтр.

После этого мы должны кэшировать слой, чтобы эффекты фильтра были видны, поэтому мы проверяем, уже кэшировали ли мы этот слой (например, при его обрезке), и вызываем updateCache()или, cache()если необходимо.

Вот изображение, которое я буду использовать для демонстрации эффектов фильтров:



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

Вот код (поместите его в filters.jsфайл):

1
2
3
4
filterBrightness = function (value) {
    applyFilter(new ColorFilter(value, value, value, 1));
    hideDialog('#dialog-filterbrightness');
}

Как я упоминал ранее, наша вспомогательная функция делает все за нас, нам нужно только создать новый фильтр. Здесь мы создаем ColorFilterс мультипликаторами красного, зеленого, синего, установленными на, valueи альфа, установленными на 1,0 (мы не хотим, чтобы этот фильтр касался альфы).

Ниже приведен пример результата этого фильтра:



Colorify увеличит или уменьшит значение некоторых каналов для изменения общего цвета изображения, поэтому мы снова будем использовать ColorFilter.

Посмотрите на код:

1
2
3
4
filterColorify = function (r, g, b, a) {
    applyFilter(new ColorFilter(1.0, 1.0, 1.0, 1.0, r, g, b, a));
    hideDialog('#dialog-filtercolorify');
}

Опять же, грязная работа обрабатывается applyFilter, и мы сосредоточены только на создании объекта фильтра. Здесь мы будем использовать последние четыре параметраColorFilter конструктора. Они добавляются в каналы, поэтому они идеально соответствуют нашим потребностям.

Ниже приведен пример результата этого фильтра:



Десатурация — это процесс удаления насыщенности — простыми словами, делая изображение черно-белым. Для этого нам нужно рассчитать яркость каждого пикселя и установить для всех цветов это значение. Самое простое уравнение светимости включает в себя только добавление одинакового количества всех цветов, и для этого мы можем использовать ColorMatrixFilter:

01
02
03
04
05
06
07
08
09
10
11
filterDesaturation = function () {
    applyFilter(new ColorMatrixFilter(
        [
            0.33, 0.33, 0.33, 0.00, 0.00,
            0.33, 0.33, 0.33, 0.00, 0.00,
            0.33, 0.33, 0.33, 0.00, 0.00,
            0.00, 0.00, 0.00, 1.00, 0.00
        ]
    ));
    hideDialog('#dialog-filterbrightness');
}

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

Нет примера изображения результата, потому что оно уже черно-белое; фильтр не имеет никакого эффекта.


Это Convolution Filterнемного более продвинутый, чем ColorMatrixFilter. Он также использует матрицу, но матрица свертки представляет множители пикселей, окружающих фактический пиксель.

Допустим, у нас есть пример матрицы свертки 3х3 (уже представленной в виде массива JavaScript):

1
2
3
4
5
[
    [ 0, 0, 0],
    [-1, 1, 0],
    [ 0, 0, 0]
]

И (например) мы смотрим на часть изображения, где пиксели выглядят так (каждое число представляет силу канала красного цвета; мы игнорируем остальные для простоты):

С помощью фильтра свертки мы модифицируем пиксель посередине (текущее значение: 56). Итак, мы начинаем с умножения каждого значения цвета вокруг этого пикселя на его множитель из массива свертки, а затем мы складываем их вместе. Мы получаем следующее уравнение:

Итак, теперь мы устанавливаем значение красного канала нового пикселя равным 44 — но в новом массиве данных, потому что нам все еще нужно держаться за старое значение 56 для изменения других пикселей в изображении. Это означает, что при применении фильтра мы фактически создаем копию изображения, а не модифицируем существующую копию на месте.

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

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

Также вы должны помнить, что сумма всех значений в матрице свертки должна быть равна нулю или единице, иначе вы получите странные результаты. Чтобы сделать это проще , мы можем использовать factorи в offsetпеременные. После вычисления значения пикселя (с помощью приведенного выше уравнения) мы умножаем все значение на factorи добавляем offset. Это упрощает создание, например, фильтра размытия (который мы получим через минуту), где все значения матрицы свертки совпадают.

К сожалению, ConvolutionFilterв EaselJS нет реализации, поэтому мы должны написать ее. Следуя примеру ColorFilterя создал этот код:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
(function (window) {
    var ConvolutionFilter = function (matrix, factor, offset) {
        this.initialize(matrix, factor, offset);
    }
      
    var p = ConvolutionFilter.prototype = new Filter();
      
    p.matrix = null;
    p.factor = 0.0;
    p.offset = 0.0;
          
    p.initialize = function (matrix, factor, offset) {
        this.matrix = matrix;
        this.factor = factor;
        this.offset = offset;
    }
      
    p.applyFilter = function (ctx, x, y, width, height, targetCtx, targetX, targetY) {
        targetCtx = targetCtx || ctx;
        targetX = (targetX == null ? x: targetX);
        targetY = (targetY == null ? y: targetY);
          
        try {
            var imageData = ctx.getImageData(x, y, width, height);
        } catch (e) {
            return false;
        }
          
        var data = JSON.parse(JSON.stringify(imageData.data));
          
        var matrixhalf = Math.floor(this.matrix.length / 2);
        var r = 0, g = 1, b = 2, a = 3;
          
        for (var y = 0; y < height; y++) {
            for (var x = 0; x < width; x++) {
                var pixel = (y * width + x) * 4,
                    sumr = 0, sumg = 0, sumb = 0;
                for (var matrixy in this.matrix) {
                    for (var matrixx in this.matrix[matrixy]) {
                        var convpixel = ((y + (matrixy - matrixhalf)) * width + (x + (matrixx - matrixhalf))) * 4;
                        sumr += data[convpixel + r] * this.matrix[matrixy][matrixx];
                        sumg += data[convpixel + g] * this.matrix[matrixy][matrixx];
                        sumb += data[convpixel + b] * this.matrix[matrixy][matrixx];
                    }
                }
                imageData.data[pixel + r] = this.factor * sumr + this.offset;
                imageData.data[pixel + g] = this.factor * sumg + this.offset;
                imageData.data[pixel + b] = this.factor * sumb + this.offset;
                imageData.data[pixel + a] = data[pixel + a];
            }
        }
          
        targetCtx.putImageData(imageData, targetX, targetY);
        return true;
    }
  
    p.toString = function() {
        return "[ConvolutionFilter]";
    }
      
    p.clone = function() {
        return new ConvolutionFilter(this.matrix, this.factor, this.offset);
    }
      
    window.ConvolutionFilter = ConvolutionFilter;
}(window));

Вы можете пропустить все методы, кроме applyFilter, поскольку они используются EaselJS для инициализации фильтра.

applyFilter()вызывается, когда мы применяем фильтр к изображению. Сначала мы должны получить данные изображения из canvas, затем я использую трюк с JSON.parse(JSON.stringify(imageData.data))— потому что мы хотим получить копию данных изображения, а imageData.dataобъект не имеет clone()илиslice() метода чтобы достичь этого, поэтому мы используем этот хитрый способ, чтобы полностью скопировать объект и все его свойства.

Информация о цвете хранится в этих данных следующим образом:

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


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

01
02
03
04
05
06
07
08
09
10
11
12
13
filterBlur = function (radius) {
    var matrix = [];
      
    for (var y = 0; y < radius * 2; y++) {
        matrix[y] = [];
        for (var x = 0; x < radius * 2; x++) {
            matrix[y][x] = 1;
        }
    }
      
    applyFilter(new ConvolutionFilter(matrix, 1.0 / Math.pow(radius * 2, 2), 0.0));
    hideDialog('#dialog-filterblur');
}

Он генерирует матрицу размытия, затем применяет фильтр.

Почему мы настроены factorна Math.pow(radius * 2, 2)? Потому что, как я сказал ранее: сумма всех полей массива должна быть равна нулю или единице; если мы разделим их всех на их сумму, мы всегда получим 1.

Ниже приведен результат этого фильтра:




Этот сверточный фильтр называется так, потому что он использует значения из стандартного гауссова распределения (на рисунке выше), вставленные в матрицу свертки. Чтобы упростить задачу, мы позволяем пользователю выбрать только три значения радиуса, потому что применение фильтра для большего радиуса займет слишком много времени (радиус 3 пикселя уже является матрицей 7 на 7).

Вот функция:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
var gaussMatrix = [
    [
        [ 0.05472157, 0.11098164, 0.05472157 ],
        [ 0.11098164, 0.22508352, 0.11098164 ],
        [ 0.05472157, 0.11098164, 0.05472157 ]
    ],
    [
        [ 0.00078633, 0.00655965, 0.01330373, 0.00655965, 0.00078633 ],
        [ 0.00655965, 0.05472157, 0.11098164, 0.05472157, 0.00655965 ],
        [ 0.01330373, 0.11098164, 0.22508352, 0.11098164, 0.01330373 ],
        [ 0.00655965, 0.05472157, 0.11098164, 0.05472157, 0.00655965 ],
        [ 0.00078633, 0.00655965, 0.01330373, 0.00655965, 0.00078633 ]
    ],
    [
        [ 0.00000067, 0.00002292, 0.00019117, 0.00038771, 0.00019117, 0.00002292, 0.00000067 ],
        [ 0.00002292, 0.00078633, 0.00655965, 0.01330373, 0.00655965, 0.00078633, 0.00002292 ],
        [ 0.00019117, 0.00655965, 0.05472157, 0.11098164, 0.05472157, 0.00655965, 0.00019117 ],
        [ 0.00038771, 0.01330373, 0.11098164, 0.22508352, 0.11098164, 0.01330373, 0.00038771 ],
        [ 0.00019117, 0.00655965, 0.05472157, 0.11098164, 0.05472157, 0.00655965, 0.00019117 ],
        [ 0.00002292, 0.00078633, 0.00655965, 0.01330373, 0.00655965, 0.00078633, 0.00002292 ],
        [ 0.00000067, 0.00002292, 0.00019117, 0.00038771, 0.00019117, 0.00002292, 0.00000067 ]
    ]
];
  
filterGaussianBlur = function (radius) {
    applyFilter(new ConvolutionFilter(gaussMatrix[radius], 1.0, 0.0));
    hideDialog('#dialog-filtergaussianblur');
}

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

Ниже приведен результат этого фильтра:




Обнаружение краев — это метод, часто используемый в ИИ роботов, чтобы помочь им перемещаться, потому что обнаружение краев оставляет только края изображения. Это также очень хороший эффект для использования в искусстве.

Для достижения этого мы используем приближение первых значений из распределения Лапласа (изображение выше) с b = 1/4. Все функции из этой точки будут иметь только разные матрицы:

01
02
03
04
05
06
07
08
09
10
11
12
filterEdgeDetection = function () {
    applyFilter(new ConvolutionFilter(
        [
            [ 0, -1, 0 ],
            [ -1, 4, -1 ],
            [ 0, -1, 0 ]
        ],
        1.0,
        0.0
    ));
    hideDialog('#dialog-filteredgedetection');
}

Ниже приведен результат этого фильтра:



Этот фильтр аналогичен предыдущему, но он усиливает края, не затемняя остальную часть изображения, что делает его идеальным для художественного использования. Это на самом деле матрица, которую я использовал, чтобы объяснить вам, как работают фильтры свертки:

01
02
03
04
05
06
07
08
09
10
11
12
filterEdgeEnhance = function () {
    applyFilter(new ConvolutionFilter(
        [
            [ 0, 0, 0 ],
            [ -1, 1, 0 ],
            [ 0, 0, 0 ]
        ],
        1.0,
        0.0
    ));
    hideDialog('#dialog-filteredgeenhance');
}

Ниже приведен результат этого фильтра:



Фильтр «Тиснение» добавляет небольшой трехмерный эффект к изображению, выделяя левые нижние углы краев (так что это также фильтр обнаружения краев).

Функция:

01
02
03
04
05
06
07
08
09
10
11
12
filterEmboss = function () {
    applyFilter(new ConvolutionFilter(
        [
            [ -1, -1, 0 ],
            [ -1, 1, 1 ],
            [ 0, 1, 1 ]
        ],
        1.0,
        0.0
    ));
    hideDialog('#dialog-filteremboss');
}

Ниже приведен результат этого фильтра:



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

01
02
03
04
05
06
07
08
09
10
11
12
filterSharpen = function () {
    applyFilter(new ConvolutionFilter(
        [
            [ 0, -1, 0 ],
            [ -1, 5, -1 ],
            [ 0, -1, 0 ]
        ],
        1.0,
        0.0
    ));
    hideDialog('#dialog-filtersharpen');
}

Ниже приведен результат этого фильтра:


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

Это был последний из фильтров, но вы можете добавить больше, если хотите. Просто найдите некоторые в Интернете или экспериментируйте, чтобы создать свои собственные уникальные.


Это будет следующая связка вызовов jQuery, но перед этим нам понадобится наша вторая вспомогательная функция в этом файле:

01
02
03
04
05
06
07
08
09
10
filterSwitch = function (e, val, func) {
    switch (e.type) {
        case "click":
            func(val);
            break;
        case "keydown":
            if (e.keyCode == 13) func(val);
            break;
    }
}

Требуется три параметра:

  • e — объект события,
  • val — значение для передачи в функцию, и
  • func — функция для вызова с предыдущим значением.
  • Это создает сокращение, которое мы можем использовать в следующем коде обратного вызова; просто вставьте его под помощник:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
app.callbacks.filterBrightness = function (e) {
    var val = $('#dialog-filterbrightness input').val() / 100;
    filterSwitch(e, val, filterBrightness);
}
  
app.callbacks.filterDesaturation = function () {
    filterDesaturation();
}
  
app.callbacks.filterColorify = function (e) {
    var r = $('#dialog-filtercolorify input.r').val() * 1,
        g = $('#dialog-filtercolorify input.g').val() * 1,
        b = $('#dialog-filtercolorify input.b').val() * 1,
        a = $('#dialog-filtercolorify input.a').val() * 1;
    switch (e.type) {
        case "click":
            filterColorify(r, g, b, a);
            break;
        case "keydown":
            if (e.keyCode == 13) filterColorify(r, g, b, a);
            break;
    }
}
  
app.callbacks.filterBlur = function (e) {
    var val = $('#dialog-filterblur input').val() * 1;
    filterSwitch(e, val, filterBlur);
}
  
app.callbacks.filterGaussianBlur = function (e) {
    var val = ($('#dialog-filtergaussianblur input.3').attr('checked') ? 2: $('#dialog-filtergaussianblur input.2').attr('checked') ? 1: 0);
    filterSwitch(e, val, filterGaussianBlur);
}
  
app.callbacks.filterEdgeDetection = function (e) {
    filterEdgeDetection();
}
  
app.callbacks.filterEdgeEnhance = function (e) {
    filterEdgeEnhance();
}
  
app.callbacks.filterEmboss = function (e) {
    filterEmboss();
}
  
app.callbacks.filterSharpen = function (e) {
    filterSharpen();
}

Предоставление пользователю возможности использовать некоторый язык сценариев в вашем приложении — очень полезная функция. Это позволяет пользователю автоматизировать свою работу, или когда он достигает какого-то приятного эффекта, он может поделиться им с кем-то еще, и этот человек тоже получит тот же эффект. И поскольку мы пишем все приложение на JavaScript — который сам является языком сценариев — очень легко создать такую ​​функцию.

Используя eval()функцию, мы можем запустить некоторый JavaScript из строки, и эта строка будет скриптом пользователя.

Вы, наверное, читали, что использование этой eval()функции — очень плохая практика. Конечно, если вам нужно использовать его внутри вашего кода, тогда это предложение верно, потому что это отключает любое кэширование или компиляцию, которые современные движки JavaScript используют для ускорения кода. Он также создает другой экземпляр синтаксического анализатора JavaScript, который тратит память до тех пор, пока не завершит работу с кодом. Поэтому избегайте использования eval()как это:

1
2
3
4
var myEvilCondition = "someObjectPropertyButIDontKnowWhichOne";
function evilEval () {
    return eval('myObject.' + myEvilCondition);
}

Код выше очень плохой . Вы никогда не должны использовать eval()как это. Приведенный выше пример можно исправить с помощью квадратных скобок:

1
2
3
4
var myNotEvilCondition = "someObjectPropertyButIDontKnowWhichOne";
function goodNoEval () {
    return myObject[myNotEvilCondition];
}

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


Поскольку мы выполняем пользовательские сценарии, мы должны быть уверены, что он случайно не уничтожит результат, над которым он работал, потому что он неправильно набрал имена функций или номера слоев. Вот почему он должен убедиться, что пользователь не может получить доступ к каким- windowлибо appметодам напрямую.

По этой причине мы делаем нашу функцию похожей на это:

1
2
3
4
5
scriptExecute = function (code) {
    hideDialog('#dialog-executescript');
    if ((code.match(/eval\(/g) != null) && (!confirm('You used the eval function inside of your code. This may lead to unexpected effects, do you want to continue?'))) return;
    eval(code.replace(/(window\.|app\.)(.*?);/g, ''));
}

(Поместите этот код в scripts.jsфайл.)

Сначала мы скрываем диалог, потому что в противном случае он просто зависнет там, пока скрипт не завершится, и, возможно, пользователь захочет увидеть свой скрипт на работе. Затем мы вызываем предоставленный код, но мы заменяем все windowи appсвязанные вызовы, поэтому пользователь не может удалить все слои или закрыть окно по ошибке.

Небольшое предупреждение: пользователь все еще может что-то сделать с этими переменными, если он использует eval()функцию в своем коде — но затем мы спрашиваем его, действительно ли он хочет это сделать.

Теперь мы должны добавить эту маленькую функцию обратного вызова:

1
2
3
app.callbacks.scriptExecute = function (e) {
    scriptExecute($('#dialog-executescript textarea').val());
}

Это полная система сценариев. Идите и проверьте это, передав хороший код в диалог «Выполнение скрипта». Попробуйте использовать eval()там, чтобы увидеть, что он спрашивает вас, действительно ли вы хотите это сделать.


Как вы можете видеть, HTML5 Canvas — мощная вещь. Но мы только поцарапали поверхность того, что можно с этим сделать. Мы создали действительно продвинутое приложение, которое позволяет пользователям загружать свои фотографии и вносить в них некоторые изменения, а затем сохранять и распечатывать отредактированные фотографии — используя чистый JavaScript. Несколько лет назад это было бы шуткой.

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

Спасибо за чтение этого урока, надеюсь, я действительно научил вас чему-то, что вы будете использовать в каком-то большом проекте. Если вам нужна помощь в создании чего-то продвинутого с HTML5, не стесняйтесь спрашивать меня на мой контактный адрес электронной почты или добавив комментарий к этому учебнику. Я отвечу вам, как только получу ваше сообщение.