{{meta {load_files: [«code/chapter/19_paint.js»], zip: «html include=[\«css/paint.css\»]"}}}
{{quote {автор:
«Joan Miró», chapter: true}} {{quote {author: “Joan Miró”, chapter: true}}
Я дивлюся на безліч кольорів переді мною. Я дивлюся на своє чисте полотно. Потім я намагаюся застосувати кольори, як слова, що формують вірші, як ноти, що формують музику.
quote}}
{{index «Miró, Joan», «example of drawing program», «project chapter»}}
{{figure {url: «img/chapter_picture_19.jpg», alt: «Ілюстрація із зображенням мозаїки з чорних плиток, з банками інших плиток поруч», »chapter: «Обрамлення"}}}.
Матеріал попередніх розділів дає вам всі елементи, необхідні для створення базового ((веб-додатку)). У цій главі ми зробимо саме це.
{{index [файл, зображення]}}
Наш ((додаток)) буде ((піксель))-((малюнок)) програмою, яка дозволяє змінювати зображення піксель за пікселем, маніпулюючи його збільшеним зображенням, показаним у вигляді сітки з кольорових квадратів. За допомогою програми можна відкривати файли зображень, малювати на них мишею або іншим вказівним пристроєм і зберігати їх. Ось як це буде виглядати:
{{figure {url: «img/pixel_editor.png», alt: «Скріншот інтерфейсу піксельного редактора, з сіткою кольорових пікселів вгорі і рядом елементів керування у вигляді HTML полів і кнопок внизу», width: “8cm”}}}}
Малювати на комп'ютері - це чудово. Вам не потрібно турбуватися про матеріали, ((навички)) або талант. Ви просто починаєте мазати і бачите, що вийшло.
{{index drawing, «select (HTML-тег)», «canvas (HTML-тег)», component}}
Інтерфейс програми показує великий елемент <canvas>
зверху, з кількома формами ((поле)) під ним. Користувач малює на ((малюнок)), обираючи інструмент у полі <select>
, а потім клацає, ((торкається)) або ((перетягує)) по полотну. Існують інструменти для малювання окремих пікселів або прямокутників, заповнення області та вибору кольору на зображенні.
{{index [DOM, компоненти]}}
Ми структуруємо інтерфейс редактора як низку об'єктів ((component))s, які відповідають за частину DOM і можуть містити всередині себе інші компоненти.
{{index [state, «of application»]}}
Стан програми складається з поточного зображення, вибраного інструменту та вибраного кольору. Ми налаштуємо так, щоб стан зберігався у єдиному значенні, а компоненти інтерфейсу завжди базували свій вигляд на поточному стані.
Щоб зрозуміти, чому це важливо, давайте розглянемо альтернативний розподіл фрагментів стану по всьому інтерфейсу. До певного моменту це легше програмувати. Ми можемо просто додати ((поле кольору)) і прочитати його значення, коли нам потрібно дізнатися поточний колір.
Але потім ми додаємо ((піпетку кольору)) - інструмент, який дозволяє клацнути по зображенню, щоб вибрати колір певного пікселя. Щоб колірне поле показувало правильний колір, цей інструмент повинен знати, що колірне поле існує, і оновлювати його щоразу, коли він вибирає новий колір. Якщо ви коли-небудь додасте інше місце, яке робить колір видимим (можливо, курсор миші може показувати його), вам доведеться оновити код зміни кольору, щоб він також був синхронізований.
{Модульність індексів
По суті, це створює проблему, коли кожна частина інтерфейсу повинна знати про всі інші частини, що не є дуже модульним. Для невеликих програм, подібних до тієї, що описано у цій главі, це може не бути проблемою. Для великих проектів це може перетворитися на справжній кошмар.
Щоб уникнути цього кошмару в принципі, ми будемо суворо дотримуватися ((потоку даних)). Є стан, і інтерфейс малюється на основі цього стану. Компонент інтерфейсу може реагувати на дії користувача, оновлюючи стан, і тоді компоненти отримують можливість синхронізувати себе з цим новим станом.
{{індексна бібліотека, фреймворк}}
На практиці, кожен ((компонент)) налаштовується так, що коли йому надається новий стан, він також повідомляє про це свої дочірні компоненти, якщо ті потребують оновлення. Налаштування цього є дещо клопіткою справою. Зробити це зручнішим - основна перевага багатьох бібліотек програмування для браузерів. Але для такого невеликого додатку, як цей, ми можемо обійтися без такої інфраструктури.
{{index [state, transitions]}}
Оновлення стану представляються у вигляді об'єктів, які ми будемо називати ((action))s. Компоненти можуть створювати такі дії і ((відправляти)) їх - передавати центральній функції управління станом. Ця функція обчислює наступний стан, після чого інтерфейсні компоненти оновлюються до цього нового стану.
{{index [DOM, components]}}
Ми беремо на себе брудну задачу запуску ((користувацького інтерфейсу)) і застосування до нього ((структури)). Хоча частини, пов'язані з DOM, все ще повні ((побічних ефектів)), вони тримаються на концептуально простому кістяку: циклі оновлення стану. Стан визначає, як виглядає DOM, і єдиний спосіб, у який події DOM можуть змінити стан, - це надсилати дії до стану.
{{index «data flow»}}
Існує багато варіантів цього підходу, кожен з яких має свої переваги та проблеми, але їх центральна ідея однакова: зміни стану повинні проходити через один чітко визначений канал, а не відбуватися повсюдно.
{{index «dom property», [interface, object]}}
Наші ((компонент))и будуть ((клас))ами, що відповідають інтерфейсу. Їхньому конструктору надається стан - який може бути повним станом програми або меншим значенням, якщо йому не потрібен доступ до всього - і він використовує його для створення властивості dom
. Це елемент DOM, який представляє компонент. Більшість конструкторів також приймають деякі інші значення, які не змінюються з часом, наприклад, функцію, яку вони можуть використовувати для ((відправлення)) дії.
{{index «syncState method»}}
Кожен компонент має метод syncState, який використовується для синхронізації його з новим значенням стану. Метод отримує один аргумент, стан, який має той самий тип, що і перший аргумент його конструктора.
{{індекс «клас зображення», «властивість зображення», «властивість інструмента», «властивість кольору»}}
Стан програми буде об'єктом з властивостями picture
, tool
та color
. Зображення саме по собі є об'єктом, який зберігає ширину, висоту та піксельний вміст зображення. Пікселі ((pixel)) зберігаються у єдиному масиві, рядок за рядком, зверху вниз.
class Picture {
constructor(width, height, pixels) {
this.width = width
this.height = height
this.pixels = pixels;
}
static empty(width, height, color) {
let pixels = new Array(width * height).fill(color);
return new Picture(width, height, pixels);
}
pixel(x, y) {
return this.pixels[x + y * this.width];
}
draw(pixels) {
let copy = this.pixels.slice();
for (let {x, y, color} of pixels) {
copy[x + y * this.width] = color;
}
return new Picture(this.width, this.height, copy);
}
}
{{index «side effect», «persistent data structure»}}
Ми хочемо мати можливість обробляти зображення як ((незмінне)) значення, з причин, до яких ми повернемося пізніше у цій главі. Але іноді нам також потрібно оновити цілу групу пікселів за один раз. Для цього у класі передбачено метод draw
, який очікує масив оновлених пікселів - об'єктів з властивостями x
, y
та color
- і створює нове зображення з перезаписаними пікселями. Цей метод використовує slice
без аргументів для копіювання всього масиву пікселів - початок зрізу за замовчуванням дорівнює 0, а кінець - довжині масиву.
{{індекс «Конструктор масиву», «метод заповнення», [«властивість довжини», «для масиву»], [масив, створення]}}
Метод empty
використовує дві частини функціональності масиву, які ми не бачили раніше. Конструктор Array
можна викликати з числом для створення порожнього масиву заданої довжини. Метод fill
можна використовувати для заповнення цього масиву заданим значенням. Ці методи використовуються для створення масиву, у якому всі пікселі мають однаковий колір.
{{індекс «шістнадцяткове число», «компонент кольору», «поле кольору», «властивість fillStyle»}}
Кольори зберігаються у вигляді рядків, що містять традиційні ((CSS)) ((код кольору)), які складаються з ((хеш-знак)) (#
) та шести шістнадцяткових (основа 16) цифр - дві для ((червоного)) компонента, дві для ((зеленого)) компонента та дві для ((синього)) компонента. Це дещо загадковий і незручний спосіб запису кольорів, але саме такий формат використовується у полі введення кольорів HTML, і його можна використовувати у властивості fillStyle
контексту малювання полотна, тому для способів використання кольорів у цій програмі він є достатньо практичним.
{{index black}}
Чорний, де всі компоненти дорівнюють нулю, записується «#000000»
, а яскравий ((рожевий)) виглядає як «#ff00ff»
, де червона і синя компоненти мають максимальне значення 255, записується ff
у шістнадцятковій системі числення ((цифра)) (яка використовує a до f для представлення цифр від 10 до 15).
{{index [стан, переходи]}}
Ми дозволимо інтерфейсу ((dispatch)) ((action))s як об'єкти, властивості яких перезаписують властивості попереднього стану. Поле кольору, коли користувач змінює його, може відправити об'єкт на кшталт {color: field.value}
, з якого ця функція оновлення може обчислити новий стан.
{{index «updateState function»}}
function updateState(state, action) {
return {...state, ...action};
}
{{index «символ крапки»}}
Цей патерн, в якому об'єкт ((spread)) використовується для того, щоб спочатку додати властивості до існуючого об'єкту, а потім перевизначити деякі з них, є поширеним в JavaScript коді, який використовує об'єкти ((immutable)).
{{index «createElement method», «elt function», [DOM, construction]}}
Однією з основних речей, які роблять інтерфейсні компоненти, є створення структури DOM. Ми знову ж таки не хочемо безпосередньо використовувати для цього багатослівні методи DOM, тому пропонуємо дещо розширену версію функції elt
:
function elt(type, props, ...children) {
let dom = document.createElement(type);
if (props) Object.assign(dom, props);
for (let child of children) {
if (typeof child != «string») dom.appendChild(child);
else dom.appendChild(document.createTextNode(child));
}
return dom;
}
{{index «метод setAttribute», «атрибут», «властивість onclick», «подія кліку», «обробка події»}}
Основна відмінність цієї версії від тієї, яку ми використовували у Глава ? полягає у тому, що вона призначає властивості вузлам DOM, а не атрибути. Це означає, що ми не можемо використовувати його для встановлення довільних атрибутів, але ми можемо використовувати його для встановлення властивостей, значення яких не є рядком, наприклад, onclick
, який можна встановити у функції для реєстрації обробника події кліку.
{{index «button (HTML-тег)»}}
Це дозволяє використовувати цей зручний стиль для реєстрації обробників подій:
<body
<script>
document.body.appendChild(elt(«button», {
onclick: () => console.log(«click»)
}, «Кнопка»));
</script> </span> </span> </span> </span> </span
</body> </body
Перший компонент, який ми визначимо, - це частина інтерфейсу, яка відображає зображення у вигляді сітки кольорових клітинок. Цей компонент відповідає за дві речі: показує картинку та передає ((подію вказівника)) на цю картинку решті програми.
{{index «PictureCanvas class», «callback function», «scale constant», «canvas (HTML tag)», «mousedown event», «touchstart event», [state, «of application»]}}
Отже, ми можемо визначити його як компонент, який знає лише про поточну картинку, а не про весь стан програми. Оскільки він не знає, як працює програма в цілому, він не може безпосередньо відправляти ((дію))и. Натомість, реагуючи на події вказівника, він викликає функцію зворотного виклику, надану кодом, який його створив, яка обробляє специфічні для програми частини.
const scale = 10;
class PictureCanvas {
constructor(picture, pointerDown) {
this.dom = elt(«canvas», {
onmousedown: event => this.mouse(event, pointerDown),
ontouchstart: event => this.touch(event, pointerDown)
});
this.syncState(picture);
}
syncState(picture) {
if (this.picture == picture) return;
this.picture = picture;
drawPicture(this.picture, this.dom, scale);
}
}
{{index «syncState method», efficiency}}
Ми малюємо кожен піксель у вигляді квадрату 10 на 10, що визначається константою scale
. Щоб уникнути зайвої роботи, компонент відстежує поточну картинку і перемальовує її лише тоді, коли синхронізація
отримує нове зображення.
{{index «drawPicture function»}}
Власне функція малювання встановлює розмір полотна на основі масштабу та розміру зображення і заповнює його серією квадратів, по одному на кожен піксель.
функція drawPicture(picture, canvas, scale) {
canvas.width = picture.width * scale;
canvas.height = picture.height * scale;
let cx = canvas.getContext(«2d»);
for (let y = 0; y < picture.height; y++) {
for (let x = 0; x < picture.width; x++) {
cx.fillStyle = picture.pixel(x, y);
cx.fillRect(x * scale, y * scale, scale, scale);
}
}
}
{{index «mousedown event», «mousemove event», «button property», «buttons property», «pointerPosition function»}}
При натисканні лівої кнопки миші, коли курсор миші знаходиться над полотном зображення, компонент викликає функцію зворотного виклику pointerDown
, передаючи їй позицію пікселя, на який було натиснуто, у координатах зображення. Це буде використано для реалізації взаємодії миші з зображенням. Функція зворотного виклику може повертати іншу функцію зворотного виклику, яка буде сповіщати про переміщення вказівника на інший піксель при натиснутій кнопці.
PictureCanvas.prototype.mouse = function(downEvent, onDown) {
if (downEvent.button != 0) return;
let pos = pointerPosition(downEvent, this.dom);
let onMove = onDown(pos);
if (!onMove) return;
let move = moveEvent => {
if (moveEvent.buttons == 0) {
this.dom.removeEventListener(«mousemove», move);
} else {
нехай newPos = pointerPosition(moveEvent, this.dom);
if (newPos.x == pos.x && newPos.y == pos.y) return;
pos = newPos;
onMove(newPos);
}
};
this.dom.addEventListener(«mousemove», move);
};
function pointerPosition(pos, domNode) {
let rect = domNode.getBoundingClientRect();
return {x: Math.floor((pos.clientX - rect.left) / scale),
y: Math.floor((pos.clientY - rect.top) / scale)};
}
{{index «метод getBoundingClientRect», «властивість clientX», «властивість clientY»}}
Оскільки ми знаємо розмір ((pixel))s і можемо використовувати getBoundingClientRect
для знаходження положення полотна на екрані, можна перейти від координат подій миші (clientX
і clientY
) до координат зображення. Вони завжди округлюються вниз, щоб посилатися на конкретний піксель.
{{index «touchstart event», «touchmove event», «preventDefault method»}}
З подіями дотику ми маємо зробити щось подібне, але з використанням інших подій і обов'язковим викликом preventDefault
на події «touchstart»
для запобігання ((панорамування)).
PictureCanvas.prototype.touch = function(startEvent,
onDown) {}}
let pos = pointerPosition(startEvent.touches[0], this.dom);
let onMove = onDown(pos);
startEvent.preventDefault();
if (!onMove) return;
let move = moveEvent => {
let newPos = pointerPosition(moveEvent.touches[0],
this.dom);
if (newPos.x == pos.x && newPos.y == pos.y) return;
pos = newPos;
onMove(newPos);
};
let end = () => {
this.dom.removeEventListener(«touchmove», move);
this.dom.removeEventListener(«touch», end);
};
this.dom.addEventListener(«touchmove», move);
this.dom.addEventListener(«touchend», end);
};
{{index «touches property», «clientX property», «clientY property»}}
Для подій дотику, clientX
та clientY
не доступні безпосередньо на об'єкті події, але ми можемо використовувати координати першого об'єкту дотику у властивості touches
.
Для того, щоб можна було збирати додаток по частинах, ми реалізуємо головний компонент як оболонку навколо полотна зображення і динамічного набору ((tool))s та ((control))s, які ми передаємо його конструктору.
Елементи управління - це елементи інтерфейсу, які з'являються під зображенням. Вони будуть надані у вигляді масиву конструкторів ((компонент)).
{{index «br (HTML-тег)», «flood fill», «select (HTML-тег)», «PixelEditor class», dispatch}}
Інструменти виконують такі дії, як малювання пікселів або заливка області. Програма показує набір доступних інструментів у вигляді поля <select>
. Поточно вибраний інструмент визначає, що відбувається, коли користувач взаємодіє з зображенням за допомогою вказівного пристрою. Набір доступних інструментів надається у вигляді об'єкта, який відображає назви, що з'являються у випадаючому полі, на функції, які реалізують ці інструменти. Такі функції отримують позицію зображення, поточний стан програми та функцію dispatch
як аргументи. Вони можуть повертати функцію-обробник переміщення, яка викликається з новою позицією і поточним станом, коли вказівник переміщується на інший піксель.
class PixelEditor {
constructor(state, config) {
let {tools, controls, dispatch} = config;
this.state = state;
this.canvas = new PictureCanvas(state.picture, pos => {
let tool = tools[this.state.tool];
let onMove = tool(pos, this.state, dispatch);
if (onMove) return pos => onMove(pos, this.state);
});
this.controls = controls.map(
Control => new Control(state, config));
this.dom = elt(«div», {}, this.canvas.dom, elt(«br»),
...this.controls.reduce(
(a, c) => a.concat(» », c.dom), []));
}
syncState(state) {
this.state = state;
this.canvas.syncState(state.picture);
for (let ctrl of this.controls) ctrl.syncState(state);
}
}
Обробник вказівника, переданий PictureCanvas
, викликає поточний вибраний інструмент з відповідними аргументами і, якщо той повертає обробник переміщення, адаптує його, щоб також отримувати стан.
{{index «reduce method», «map method», [пробіли, «in HTML»], «syncState method»}}
Всі елементи управління створюються та зберігаються у this.controls
, щоб їх можна було оновити при зміні стану програми. Виклик reduce
вводить пробіли між DOM-елементами елементів управління. Таким чином, вони не виглядають притиснутими один до одного.
{{index «select (HTML-тег)», «change event», «ToolSelect class», «syncState method»}}
Перший елемент управління - це меню вибору ((tool)). Він створює елемент <select>
з опцією для кожного інструмента і налаштовує обробник події «change»
, який оновлює стан програми, коли користувач вибирає інший інструмент.
class ToolSelect {
constructor(state, {tools, dispatch}) {
this.select = elt(«select», {
onchange: () => dispatch({tool: this.select.value})
}, ...Object.keys(tools).map(name => elt(«option», {
selected: name == state.tool
}, name)));
this.dom = elt(«label», null, «🖌 Tool: », this.select);
}
syncState(state) { this.select.value = state.tool; }
}
{{index «label (HTML-тег)»}}
Обертаючи текст мітки і поле в елемент <label>
, ми повідомляємо браузеру, що мітка належить до цього поля, так що ви можете, наприклад, клацнути на мітці, щоб сфокусувати поле.
{{index «color field», «input (HTML tag)»}}
Нам також потрібно мати можливість змінювати колір, тому додамо елемент керування для цього. HTML-елемент <input>
з атрибутом type
, що має значення color
, надає нам поле форми, яке спеціалізовано для вибору кольору. Значенням такого поля завжди є код кольору CSS у форматі «#RRGGBB»
(червоний, зелений і синій компоненти, по дві цифри на колір). Браузер покаже інтерфейс ((піпетка кольорів)), коли користувач буде взаємодіяти з ним.
{{if book
Залежно від браузера, піпетка кольорів може мати такий вигляд:
{{figure {url: «img/color-field.png», alt: «Знімок поля кольору», width: “6cm”}}}}
if}}
{{index «ColorSelect class», «syncState method»}}
Цей ((елемент управління)) створює таке поле і підключає його до синхронізації з властивістю color
стану програми.
class ColorSelect {
constructor(state, {dispatch}) {
this.input = elt(«input», {
тип: «color»,
value: state.color,
onchange: () => dispatch({color: this.input.value})
});
this.dom = elt(«label», null, «🎨 Колір: », this.input);
}
syncState(state) { this.input.value = state.color; }
}
Перш ніж ми зможемо щось намалювати, нам потрібно реалізувати ((інструмент))и, які будуть керувати функціональністю подій миші або дотику на полотні.
{{index «draw function»}}
Найпростішим інструментом є інструмент малювання, який змінює будь-який ((піксель)), на який ви натискаєте або торкаєтесь, на поточний вибраний колір. Він виконує дію, яка оновлює зображення до версії, у якій піксель, на який вказано, набуває поточного вибраного кольору.
function draw(pos, state, dispatch) {
function drawPixel({x, y}, state) {
let drawn = {x, y, color: state.color};
dispatch({picture: state.picture.draw([drawn])});
}
drawPixel(pos, state);
return drawPixel;
}
Функція негайно викликає функцію drawPixel
, але потім також повертає її, щоб викликати її знову для нових пікселів, коли користувач перетягує або проводить ((свайпом)) по зображенню.
{{index «rectangle function»}}
Для малювання більших фігур може бути корисним швидке створення ((прямокутника))s. Інструмент прямокутник
((інструмент)) малює прямокутник між точкою, з якої ви починаєте ((перетягування)), і точкою, до якої ви перетягуєте.
function rectangle(start, state, dispatch) {
function drawRectangle(pos) {
let xStart = Math.min(start.x, pos.x)
let yStart = Math.min(start.y, pos.y)
let xEnd = Math.max(start.x, pos.x)
let yEnd = Math.max(start.y, pos.y);
let drawn = [];
for (let y = yStart; y <= yEnd; y++) {
for (let x = xStart; x <= xEnd; x++) {
drawn.push({x, y, color: state.color});
}
}
dispatch({picture: state.picture.draw(drawn)});
}
drawRectangle(start);
return drawRectangle;
}
{{index «persistent data structure», [state, persistence]}}
Важливою деталлю у цій реалізації є те, що при перетягуванні прямокутник перемальовується на зображенні з початкового стану. Таким чином, ви можете робити прямокутник більшим і меншим знову під час його створення, без проміжних прямокутників, що залишаються на кінцевому зображенні. Це одна з причин, чому ((незмінні)) об'єкти зображень є корисними - ми побачимо іншу причину пізніше.
Реалізація ((заливка)) дещо складніша. Це ((інструмент)), який заливає піксель під вказівником і всі сусідні пікселі, які мають такий самий колір. «Суміжні» означає безпосередньо сусідні по горизонталі або вертикалі, а не по діагоналі. Цей малюнок ілюструє набір ((пікселів)), які зафарбовуються при застосуванні інструмента заливки до позначеного пікселя:
{{figure {url: «img/flood-grid.svg», alt: «Діаграма піксельної сітки, що показує область, заповнену операцією заливки», width: “6cm”}}}}
{{index «fill function»}}
Цікаво, що спосіб, у який ми це зробимо, трохи схожий на код ((пошук шляху)) з Розділ ?. У той час як той код шукав маршрут на графі, цей код шукає по сітці, щоб знайти всі «з'єднані» пікселі. Проблема відстеження розгалуженої множини можливих маршрутів схожа.
const around = [{dx: -1, dy: 0}, {dx: 1, dy: 0},
{dx: 0, dy: -1}, {dx: 0, dy: 1}];
function fill({x, y}, state, dispatch) {
let targetColor = state.picture.pixel(x, y);
let drawn = [{x, y, color: state.color}];
let visited = new Set();
for (let done = 0; done < drawn.length; done++) {
for (let {dx, dy} of around) {
let x = drawn[done].x + dx, y = drawn[done].y + dy;
if (x >= 0 && x < state.picture.width &&
y >= 0 && y < state.picture.height &&)
!visited.has(x + «,» + y) && !visited.has(x + «,» + y) &&
state.picture.pixel(x, y) == targetColor) { })
drawn.push({x, y, color: state.color});
visited.add(x + «,» + y);
}
}
}
dispatch({picture: state.picture.draw(drawn)});
}
Масив намальованих пікселів подвоюється у функції ((робочий список)). Для кожного досягнутого пікселя ми повинні перевірити, чи сусідні пікселі мають такий самий колір і не були зафарбовані. Лічильник циклу відстає від довжини масиву «намальованих» пікселів, коли додаються нові пікселі. Будь-які пікселі перед ним ще потрібно дослідити. Коли він наздоганяє довжину масиву, не залишиться жодного недослідженого пікселя, і функція завершується.
{{index «pick function»}}
Останнім ((інструментом)) є ((піпетка)), яка дозволяє вказати на колір на зображенні, щоб використати його як поточний колір малювання.
function pick(pos, state, dispatch) {
dispatch({color: state.picture.pixel(pos.x, pos.y)});
}
{{if інтерактивний
Тепер ми можемо протестувати наш додаток!
<div></div>
<script>
let state = {
інструмент: «draw»,
color: «#000000»,
малюнок: Picture.empty(60, 30, «#f0f0f0»)
};
let app = new PixelEditor(state, {
tools: {draw, fill, rectangle, pick},
controls: [ToolSelect, ColorSelect],
dispatch(action) {
state = updateState(state, action);
app.syncState(state);
}
});
document.querySelector(«div»).appendChild(app.dom);
</script>
if}}
{{index «SaveButton class», «drawPicture function», [file, image]}}
Коли ми намалюємо наш шедевр, ми захочемо зберегти його на потім. Нам слід додати кнопку для ((завантаження)) завантаження поточного малюнка у вигляді графічного файлу. Цей ((елемент управління)) надає таку кнопку:
class SaveButton {
constructor(state) {
this.picture = state.picture;
this.dom = elt(«button», {
onclick: () => this.save()
}, «💾 Зберегти»);
}
save() {
let canvas = elt(«canvas»);
drawPicture(this.picture, canvas, 1);
let link = elt(«a», {
href: canvas.toDataURL(),
download: «pixelart.png»
});
document.body.appendChild(link);
link.click();
link.remove();
}
syncState(state) { this.picture = state.picture; }
}
{{index «canvas (HTML-тег)»}}
Компонент відстежує поточну картинку, щоб мати доступ до неї при збереженні. Для створення файлу зображення він використовує елемент <canvas>
, на якому малює картинку (у масштабі один піксель на піксель).
{{index «toDataURL метод», «URL даних»}}
Метод toDataURL
на елементі полотна створює URL-адресу, яка використовує схему data:
. На відміну від URL-адрес http:
і https:
, URL-адреси даних містять весь ресурс в URL-адресі. Зазвичай вони дуже довгі, але дозволяють створювати робочі посилання на довільні зображення прямо тут, у браузері.
{{index «a (HTML-тег)», «download attribute»}}
Щоб змусити браузер завантажити зображення, ми створюємо елемент ((посилання)), який вказує на цю URL-адресу і має атрибут download
. Такі посилання, при натисканні на них, змушують браузер показати діалогове вікно збереження файлу. Ми додаємо це посилання в документ, імітуємо натискання на нього і знову видаляємо його. За допомогою технології ((браузер)) можна зробити багато чого, але іноді це робиться досить дивним чином.
{{index «LoadButton class», control, [file, image]}}
І це ще не все. Ми також хочемо мати можливість завантажувати існуючі файли зображень у наш додаток. Для цього ми знову визначимо компонент кнопки.
class LoadButton {
constructor(_, {dispatch}) {
this.dom = elt(«button», {
onclick: () => startLoad(dispatch)
}, «📁 Load»);
}
syncState() {}
}
function startLoad(dispatch) {
let input = elt(«input», {
тип: «file»,
onchange: () => finishLoad(input.files[0], dispatch)
});
document.body.appendChild(input);
input.click();
input.remove();
}
{{index [файл, доступ], «input (HTML-тег)»}}
Щоб отримати доступ до файлу на комп'ютері користувача, нам потрібно, щоб користувач вибрав файл через поле введення файлу. Але ми не хочемо, щоб кнопка завантаження виглядала як поле введення файлу, тому ми створюємо введення файлу при натисканні кнопки, а потім вдаємо, що це саме введення файлу було натиснуто.
{{index «FileReader class», «img (HTML-тег)», «readAsDataURL method», «Picture class»}}
Коли користувач вибрав файл, ми можемо використовувати FileReader
, щоб отримати доступ до його вмісту, знову ж таки як ((URL-адресу даних)). Ця URL-адреса може бути використана для створення елемента <img>
, але оскільки ми не можемо отримати прямий доступ до пікселів такого зображення, ми не можемо створити з нього об'єкт Picture
.
function finishLoad(file, dispatch) {
if (file == null) return;
let reader = new FileReader();
reader.addEventListener(«load», () => {
let image = elt(«img», {
onload: () => dispatch({
picture: pictureFromImage(image)
}),
src: reader.result
});
});
reader.readAsDataURL(file);
}
{{index «canvas (HTML-тег)», «getImageData метод», «pictureFromImage функція»}}
Щоб отримати доступ до пікселів, ми повинні спочатку намалювати зображення до елемента <canvas>
. Контекст полотна має метод getImageData
, який дозволяє скрипту прочитати його ((pixel))s. Отже, коли зображення буде на полотні, ми зможемо отримати до нього доступ і створити об'єкт Picture
.
function pictureFromImage(image) {
let width = Math.min(100, image.width)
let height = Math.min(100, image.height);
let canvas = elt(«canvas», {width, height});
let cx = canvas.getContext(«2d»);
cx.drawImage(image, 0, 0);
let pixels = [];
let {data} = cx.getImageData(0, 0, width, height);
function hex(n) {
return n.toString(16).padStart(2, «0»);
}
for (let i = 0; i < data.length; i += 4) {
let [r, g, b] = data.slice(i, i + 3);
pixels.push(«#» + hex(r) + hex(g) + hex(b));
}
return new Picture(width, height, pixels);
}
Ми обмежимо розмір зображень до 100 на 100 пікселів, оскільки більші будуть виглядати величезними на нашому дисплеї і можуть сповільнити роботу інтерфейсу.
{{index «метод getImageData», color, transparency}}
Властивість data
об'єкта, що повертається методом getImageData
, є масивом компонентів кольору. Для кожного пікселя у прямокутнику, заданому аргументами, він містить чотири значення, які представляють червону, зелену, синю та ((альфа)) складові кольору пікселя у вигляді чисел від 0 до 255. Альфа-частина представляє непрозорість - коли вона дорівнює 0, піксель є повністю прозорим, а коли 255, він є повністю непрозорим. Для нашої мети ми можемо ігнорувати її.
{{index «шістнадцяткове число», «метод toString»}}
Дві шістнадцяткові цифри на компонент, що використовуються у наших позначеннях кольорів, точно відповідають діапазону від 0 до 255 - дві цифри з основою 16 можуть виражати 16^2^ = 256 різних чисел. Метод toString
для чисел може передавати основу як аргумент, тому n.toString(16)
створить рядкове представлення у системі числення з основою 16. Ми повинні переконатися, що кожне число займає дві цифри, тому допоміжна функція hex
викликає padStart
для додавання початкового 0, коли це необхідно.
Тепер ми можемо завантажувати і зберігати! Залишилася лише одна функція, і ми закінчимо.
Оскільки половина процесу редагування полягає у тому, щоб робити маленькі помилки і виправляти їх, важливою функцією у програмі для малювання є ((відміна історії)).
{{index «persistent data structure», [state, «of application»]}}
Для того, щоб мати змогу скасувати зміни, нам потрібно зберігати попередні версії малюнка. Оскільки зображення є ((незмінними)) значеннями, це легко. Але це потребує додаткового поля у стані додатку.
{{index «done property»}}
Ми додамо масив done
для зберігання попередніх версій ((picture)). Підтримка цієї властивості вимагає більш складної функції оновлення стану, яка додає зображення до масиву.
{{index «doneAt property», «historyUpdateState function», «Date.now function»}}
Ми не хочемо зберігати кожну зміну - лише зміни, які відбуваються через певний проміжок часу. Для цього нам знадобиться друга властивість, doneAt
, щоб відстежувати час, коли ми востаннє зберігали зображення в історії.
function historyUpdateState(state, action) {
if (action.undo == true) {
if (state.done.length == 0) return state;
return {
...state,
picture: state.done[0],
done: state.done.slice(1),
doneAt: 0
};
} else if (action.picture &&
state.doneAt < Date.now() - 1000) { } if (state.doneAt < Date.now() - 1000) {
return {
...state,
...action,
done: [state.picture, ...state.done],
doneAt: Date.now()
};
} else {
return {...state, ...action};
}
}
{{index «undo history»}}
Коли дія є дією скасування, функція бере останнє зображення з історії і робить його поточним зображенням. Вона встановлює doneAt
в нуль, так що наступна зміна гарантовано збереже зображення назад в історію, що дозволить вам повернутися до нього іншим разом, якщо ви захочете.
В іншому випадку, якщо дія містить нове зображення, а востаннє ми зберігали щось більше секунди (1000 мілісекунд) тому, властивості done
і doneAt
буде оновлено, щоб зберегти попереднє зображення.
{{index «UndoButton class», control}}
Кнопка скасування ((компонент)) не робить багато чого. Вона виконує дії скасування при натисканні і вимикається, коли немає чого скасовувати.
class UndoButton {
constructor(state, {dispatch}) {
this.dom = elt(«button», {
onclick: () => dispatch({undo: true}),
disabled: state.done.length == 0
}, «⮪ Скасувати»);
}
syncState(state) {
this.dom.disabled = state.done.length == 0;
}
}
{{index «клас PixelEditor», «константа startState», «константа baseTools», «константа baseControls», «функція startPixelEditor»}}
Щоб налаштувати програму, нам потрібно створити стан, набір ((інструментів)), набір ((елементів керування)) та функцію ((відправлення)). Ми можемо передати їх конструктору PixelEditor
для створення головного компонента. Оскільки у вправах нам потрібно буде створити декілька редакторів, спочатку визначимо деякі прив'язки.
const startState = {
tool: «draw»,
color: «#000000»,
picture: Picture.empty(60, 30, «#f0f0f0»),
done: [],
doneAt: 0
};
const baseTools = {draw, fill, rectangle, pick};
const baseControls = [
ToolSelect, ColorSelect, SaveButton, LoadButton, UndoButton
];
function startPixelEditor({state = startState,
tools = baseTools,
controls = baseControls}) {
let app = new PixelEditor(state, {
tools,
controls,
dispatch(action) {
state = historyUpdateState(state, action);
app.syncState(state);
}
});
return app.dom;
}
{{index «destructuring binding», «= operator», [property, access]}}
При деструкції об'єкта або масиву ви можете використовувати =
після імені прив'язки, щоб надати прив'язці значення a ((значення за замовчуванням)), яке використовується, коли властивість відсутня або має значення undefined
. Функція startPixelEditor
використовує це, щоб прийняти об'єкт з низкою необов'язкових властивостей як аргумент. Наприклад, якщо ви не вкажете властивість tools
, то tools
буде прив'язано до baseTools
.
Таким чином ми отримаємо на екрані справжній редактор:
<div></div>
<script>
document.querySelector(«div»)
.appendChild(startPixelEditor({}));
</script>
{{якщо інтерактивний
Продовжуйте малювати.
if}}
Технологія браузерів дивовижна. Вона надає потужний набір будівельних блоків інтерфейсу, способів стилізації та маніпулювання ними, а також інструменти для перевірки та налагодження ваших програм. Програмне забезпечення, яке ви пишете для ((браузера)), можна запустити майже на кожному комп'ютері та телефоні на планеті.
У той же час, технологія браузерів просто смішна. Щоб опанувати її, потрібно вивчити велику кількість дурних трюків і незрозумілих фактів, а стандартна модель програмування, яку вона надає, настільки проблематична, що більшість програмістів вважають за краще покрити її кількома шарами ((абстракції)), ніж мати справу з нею безпосередньо.
{{індексний стандарт, еволюція}}
Хоча ситуація, безумовно, покращується, здебільшого це відбувається у формі додавання нових елементів для усунення недоліків, що створює ще більшу ((складність)). Функцію, яку використовують мільйони веб-сайтів, насправді не можна замінити. Навіть якби це було можливо, було б важко вирішити, чим її замінити.
{{індекс «соціальні фактори», «економічні фактори», історія}}
Технології ніколи не існують у вакуумі - ми обмежені нашими інструментами та соціальними, економічними й історичними факторами, які їх створили. Це може дратувати, але загалом продуктивніше намагатися зрозуміти, як працює існуюча технічна реальність - і чому вона є саме такою, - ніж лютувати проти неї або шукати іншу реальність.
Нові ((абстракції)) можуть бути корисними. Компонентна модель і угода ((потік даних)), яку я використовував у цій главі, є грубою формою цього. Як вже згадувалося, існують бібліотеки, які намагаються зробити програмування інтерфейсу користувача більш приємним. На момент написання цієї статті [React] (https://reactjs.org/) та [Svelte] (https://svelte.dev/) були популярними, але існує ціла кустарна індустрія таких фреймворків. Якщо ви зацікавлені в програмуванні веб-додатків, я рекомендую ознайомитися з деякими з них, щоб зрозуміти, як вони працюють і які переваги надають.
У нашій програмі все ще є місце для вдосконалення. Давайте додамо ще кілька функцій у вигляді вправ.
{{index «прив'язки клавіатури (вправа)»}}
Додайте ((клавіатура)) комбінації клавіш до програми. Перша літера назви інструмента вибирає інструмент, а [ctrl]{назва клавіші}-Z або [command]{назва клавіші}-Z активує скасування.
{{index «клас PixelEditor», «атрибут tabindex», «функція elt», «подія натискання клавіші»}}
Зробіть це, модифікувавши компонент PixelEditor
. Додайте властивість tabIndex
, рівну 0, до обгорткового елемента <div>
, щоб він міг отримувати клавіатуру ((focus)). Зверніть увагу, що властивість, яка відповідає атрибуту tabindex
, називається tabIndex
з великої літери, а наша функція elt
очікує імена властивостей. Зареєструйте обробники ключових подій безпосередньо на цьому елементі. Це означає, що вам доведеться клацнути, торкнутися або перейти на вкладку програми, перш ніж ви зможете взаємодіяти з нею за допомогою клавіатури.
{{index «ctrlKey property», «metaKey property», «control key», «command key»}}
Пам'ятайте, що клавіатурні події мають властивості ctrlKey
та metaKey
(для [command]{keyname} на Mac), за допомогою яких можна дізнатися, чи утримуються ці клавіші натиснутими.
{{якщо інтерактивна
<div></div>
<script>
// Оригінальний клас PixelEditor. Розширити конструктор.
class PixelEditor {
constructor(state, config) {
let {tools, controls, dispatch} = config;
this.state = state;
this.canvas = new PictureCanvas(state.picture, pos => {
let tool = tools[this.state.tool];
let onMove = tool(pos, this.state, dispatch);
if (onMove) {
return pos => onMove(pos, this.state, dispatch);
}
});
this.controls = controls.map(
Control => new Control(state, config));
this.dom = elt(«div», {}, this.canvas.dom, elt(«br»),
...this.controls.reduce(
(a, c) => a.concat(» », c.dom), []));
}
syncState(state) {
this.state = state;
this.canvas.syncState(state.picture);
for (let ctrl of this.controls) ctrl.syncState(state);
}
}
document.querySelector(«div»)
.appendChild(startPixelEditor({}));
</script>
if}}
{{hint
{{index «прив'язка клавіатури (вправа)», «властивість клавіші», «клавіша shift»}}
Властивістю key
подій для літерних клавіш буде сама мала літера, якщо [shift]{назва клавіші} не утримується. Нас тут не цікавлять події клавіш з [shift]{ім'я_клавіші}.
{{index «keydown event»}}
Обробник «keydown»
може перевірити об'єкт події, щоб побачити, чи відповідає він жодному зі сполучень клавіш. Ви можете автоматично отримати список перших літер з об'єкта tools
, щоб вам не довелося їх виписувати.
{{index «preventDefault method»}}
Коли ключова подія збігається з ярликом, викличте для нього метод preventDefault
і ((відправте)) відповідну дію.
підказка}}
{{index «ефективне малювання (вправа)», «полотно (тег HTML)», efficiency}}
Під час малювання більшість роботи, яку виконує наш додаток, відбувається у drawPicture
. Створення нового стану та оновлення решти DOM не є дуже дорогим, але перемальовування всіх пікселів на полотні є досить складним завданням.
{{index «syncState method», «PictureCanvas class»}}
Знайдіть спосіб зробити метод синхронізації
класу PictureCanvas
швидшим, перемальовуючи лише ті пікселі, які дійсно змінилися.
{{index «drawPicture function», compatibility}}
Пам'ятайте, що drawPicture
також використовується кнопкою збереження, тому якщо ви змінюєте її, переконайтеся, що зміни не порушують старе використання, або створіть нову версію з іншою назвою.
{{index «width property», «height property»}}
Також зауважте, що зміна розміру елемента <canvas>
шляхом встановлення його властивостей width
або height
очищає його, роблячи його знову повністю прозорим.
{{якщо інтерактивний
<div></div>
<script>
// Змініть цей метод
PictureCanvas.prototype.syncState = function(picture) {
if (this.picture == picture) return;
this.picture = picture;
drawPicture(this.picture, this.dom, scale);
};
// Можливо, ви також захочете використати або змінити це
функція drawPicture(picture, canvas, scale) {
canvas.width = picture.width * scale;
canvas.height = picture.height * scale;
let cx = canvas.getContext(«2d»);
for (let y = 0; y < picture.height; y++) {
for (let x = 0; x < picture.width; x++) {
cx.fillStyle = picture.pixel(x, y);
cx.fillRect(x * scale, y * scale, scale, scale);
}
}
}
document.querySelector(«div»)
.appendChild(startPixelEditor({}));
</script>
if}}
{{hint
{{index «ефективне малювання (вправа)»}}
Ця вправа є гарним прикладом того, як ((незмінні)) структури даних можуть зробити код швидшим. Оскільки у нас є старе і нове зображення, ми можемо порівняти їх і перемалювати лише ті пікселі, що змінили колір, заощадивши у більшості випадків понад 99 відсотків роботи з малювання.
{{index «drawPicture function»}}
Ви можете або написати нову функцію updatePicture
, або використовувати функцію drawPicture
з додатковим аргументом, який може бути невизначеним або попереднім зображенням. Для кожного ((пікселя)) функція перевіряє, чи було передано попереднє зображення з таким самим кольором у цій позиції, і пропускає піксель, якщо це так.
{{index «width property», «height property», «canvas (HTML tag)»}}
Оскільки полотно очищується, коли ми змінюємо його розмір, вам також слід уникати зміни його властивостей width
і height
, коли старе і нове зображення мають однаковий розмір. Якщо вони відрізняються, що станеться після завантаження нового зображення, ви можете встановити прив'язку, яка утримує старе зображення, на null
після зміни розміру полотна, оскільки ви не повинні пропустити жодного пікселя після зміни розміру полотна.
підказка}}
{{індекс «кола (вправа)», перетягування}}
Визначте ((інструмент)) з назвою circle
, який малює зафарбоване коло під час перетягування. Центр кола лежить у точці, де починається перетягування, а його ((радіус)) визначається відстанню, на яку перетягується.
{{якщо інтерактивно
<div></div>
<script>
function circle(pos, state, dispatch) {
// Ваш код тут
}
let dom = startPixelEditor({
tools: {...baseTools, circle}
});
document.querySelector(«div»).appendChild(dom);
</script>
if}}
{{hint
{{index «кола (вправа)», «функція прямокутника»}}
Ви можете надихнутися інструментом прямокутник
. Як і у випадку з цим інструментом, під час переміщення вказівника вам слід продовжувати малювати на початковому зображенні, а не на поточному.
Щоб визначити, які пікселі зафарбовувати, можна скористатися теоремою Піфагора. Спочатку обчисліть відстань між поточним положенням вказівника і початковим положенням, взявши квадратний корінь (Math.sqrt
) з суми квадрата (x ** 2
) різниці в координатах x і квадрата різниці в координатах y. Потім обведіть квадрат пікселів навколо початкової позиції, сторони якого принаймні вдвічі перевищують ((радіус)), і зафарбуйте ті, що знаходяться в межах радіуса кола, знову ж таки використовуючи формулу Піфагора, щоб визначити їхню ((відстань)) від центру.
Переконайтеся, що ви не намагаєтеся зафарбувати пікселі, які знаходяться за межами зображення.
підказка}}
{{index «правильні лінії (вправа)», «малювання ліній»}}
Ця вправа є більш складною, ніж попередні три, і вимагатиме від вас розв'язання нетривіальної задачі. Переконайтеся, що у вас достатньо часу і терпіння перед початком роботи над цією вправою, і не падайте духом через перші невдачі.
{{index «draw function», «mousemove event», «touchmove event»}}
У більшості браузерів, коли ви вибираєте draw
(інструмент малювання) і швидко перетягуєте зображення, ви не отримуєте замкнену лінію. Скоріше, ви отримаєте крапки з проміжками між ними, оскільки події mousemove
або touchmove
не спрацювали достатньо швидко, щоб потрапити до кожного пікселя ((пікселя)).
Вдосконалити інструмент draw
, щоб він малював повну лінію. Це означає, що вам потрібно змусити функцію обробника руху запам'ятовувати попередню позицію і пов'язувати її з поточною.
Для цього, оскільки пікселі можуть знаходитися на довільній відстані один від одного, вам доведеться написати загальну функцію малювання лінії.
Лінія між двома пікселями - це з'єднаний ланцюжок пікселів, максимально прямий, що йде від початку до кінця. Сусідні по діагоналі пікселі вважаються з'єднаними. Нахилена лінія має виглядати як на малюнку ліворуч, а не як на малюнку праворуч.
{{figure {url: «img/line-grid.svg», alt: «Діаграма з двох піксельних ліній, однієї легкої, що пропускає пікселі по діагоналі, і однієї важкої, з усіма пікселями, з'єднаними по горизонталі або вертикалі», width: “6cm”}}}.
Нарешті, якщо у нас є код, який малює лінію між двома довільними точками, ми можемо також використати його для визначення інструмента line
, який малює пряму лінію між початком і кінцем перетягування.
{{якщо інтерактивно
<div></div>
<script>
// Старий інструмент малювання. Перепишіть це.
function draw(pos, state, dispatch) {
function drawPixel({x, y}, state) {
let drawn = {x, y, color: state.color};
dispatch({picture: state.picture.draw([drawn])});
}
drawPixel(pos, state);
return drawPixel;
}
function line(pos, state, dispatch) {
// Ваш код тут
}
let dom = startPixelEditor({
tools: {draw, line, fill, rectangle, pick}
});
document.querySelector(«div»).appendChild(dom);
</script>
if}}
{{hint
{{index «правильні лінії (вправа)», «малювання ліній»}}
Справа у тому, що проблема малювання піксельної лінії полягає у тому, що насправді це чотири схожі, але дещо відмінні проблеми. Намалювати горизонтальну лінію зліва направо дуже просто - ви обходите координати x і зафарбовуєте піксель на кожному кроці. Якщо лінія має невеликий нахил (менше 45 градусів або ¼π радіана), ви можете інтерполювати координату y вздовж нахилу. Вам все одно знадобиться один піксель на позицію x, а позиція y цих пікселів визначатиметься нахилом.
Але як тільки нахил переходить через 45 градусів, вам потрібно змінити спосіб обробки координат. Тепер вам потрібен один піксель на позицію y, оскільки лінія йде вгору більше, ніж вліво. А потім, коли ви перетинаєте 135 градусів, вам потрібно повернутися до циклу над координатами x, але справа наліво.
Насправді вам не потрібно писати чотири цикли. Оскільки намалювати лінію від A до B - це те саме, що намалювати лінію від B до A, ви можете поміняти місцями початкову і кінцеву позиції для ліній, що йдуть справа наліво, і розглядати їх як такі, що йдуть зліва направо.
Отже, вам потрібні два різних цикли. Перше, що має зробити ваша функція малювання ліній, це перевірити, чи різниця між x-координатами більша за різницю між y-координатами. Якщо так, то це буде горизонтальна лінія, а якщо ні, то вертикальна.
{{index «Math.abs function», «абсолютне значення»}}
Обов'язково порівнюйте абсолютні значення різниці x та y, які ви можете отримати за допомогою Math.abs
.
{{index «swapping bindings»}}
Коли ви знаєте, вздовж якої ((осі)) ви будете виконувати цикл, ви можете перевірити, чи початкова точка має вищу координату вздовж цієї осі, ніж кінцева, і поміняти їх місцями, якщо це необхідно. Лаконічний спосіб поміняти місцями значення двох прив'язок у JavaScript використовує ((деструктурування присвоєння)) таким чином:
[start, end] = [end, start];
{{округлення індексу}}
Після цього ви можете обчислити ((нахил)) лінії, який визначає, наскільки змінюється координата на іншій осі для кожного кроку, який ви робите вздовж головної осі. Таким чином, ви можете виконати цикл вздовж головної осі, одночасно відстежуючи відповідну позицію на іншій осі, і ви можете малювати пікселі на кожній ітерації. Переконайтеся, що ви округлюєте координати неосновної осі, оскільки вони можуть бути дробовими, а метод draw
погано реагує на дробові координати.
підказка}}