Bab 19 dari Eloquent JavaScript. Sebelumnya: 18 HTTP dan Form. Berikutnya: 20 Node.js.
Aku melihat banyak warna di depanku. Aku melihat kanvas kosongku. Lalu, aku mencoba menerapkan warna seperti kata yang membentuk puisi, seperti nada yang membentuk musik.
— Joan Miró

Materi dari bab-bab sebelumnya ngasih kamu semua elemen yang kamu butuhin buat ngebangun aplikasi web dasar. Di bab ini, kita bakal ngelakuin persis itu.
Aplikasi kita bakal jadi program menggambar pixel yang ngebolehin kamu memodifikasi gambar piksel demi piksel dengan memanipulasi tampilan yang di-zoom-in darinya, ditunjukin sebagai grid kotak berwarna. Kamu bisa pakai programnya buat buka file gambar, mencoret-coretnya dengan mouse atau perangkat pointer lain, dan nyimpennya. Ini bentuknya:
![]()
Menggambar di komputer itu asik. Kamu gak perlu khawatir soal bahan, keterampilan, atau bakat. Kamu tinggal mulai ngoles dan lihat kamu berakhir di mana.
Komponen
Interface buat aplikasinya nunjukin elemen <canvas> besar di atas, dengan sejumlah field form di bawahnya. Pengguna menggambar di gambarnya dengan milih tool dari field <select> lalu ngeklik, nyentuh, atau menyeret di seluruh canvas. Ada tool buat menggambar piksel tunggal atau persegi panjang, buat ngisi area, dan buat milih warna dari gambarnya.
Kita bakal nyusun interface editor sebagai sejumlah komponen, objek yang bertanggung jawab atas potongan DOM dan yang boleh berisi komponen lain di dalamnya.
State aplikasinya terdiri dari gambar saat ini, tool yang terpilih, dan warna yang terpilih. Kita bakal nyiapin biar state-nya hidup di satu nilai dan komponen interface selalu mendasarkan tampilan mereka pada state saat ini.
Buat lihat kenapa ini penting, kita pertimbangkan alternatifnya, mendistribusikan potongan state di seluruh interface. Sampai titik tertentu, ini lebih gampang diprogram. Kita bisa cuma naruh field warna dan baca nilainya waktu kita perlu tahu warna saat ini.
Tapi lalu kita nambahin color picker, tool yang ngebolehin kamu ngeklik gambar buat milih warna piksel tertentu. Buat ngejaga field warna nunjukin warna yang benar, tool itu bakal harus tahu bahwa field warna-nya ada dan ngupdate dia setiap kali dia milih warna baru. Kalau kamu pernah nambahin tempat lain yang bikin warna-nya terlihat (mungkin kursor mouse bisa nunjukinnya), kamu harus ngupdate kode ganti-warnamu buat ngejaga itu tersinkronisasi juga.
Efeknya, ini bikin masalah di mana setiap bagian interface perlu tahu soal semua bagian lain, yang gak terlalu modular. Buat aplikasi kecil kaya yang di bab ini, itu mungkin bukan masalah. Buat proyek yang lebih besar, dia bisa berubah jadi mimpi buruk yang nyata.
Buat ngehindarin mimpi buruk ini secara prinsip, kita bakal ketat soal aliran data. Ada state, dan interface-nya digambar berdasarkan state itu. Komponen interface boleh merespons aksi pengguna dengan ngupdate state-nya, di titik mana komponen-komponennya dapet kesempatan buat menyinkronkan diri mereka dengan state baru ini.
Dalam praktik, setiap komponen disiapin biar waktu dia dikasih state baru, dia juga ngasih tahu komponen anaknya, sejauh yang perlu diupdate. Nyiapin ini agak merepotkan. Bikin ini lebih praktis adalah nilai jual utama dari banyak library pemrograman browser. Tapi buat aplikasi kecil kaya ini, kita bisa ngelakuinnya tanpa infrastruktur kaya gitu.
Update ke state diwakili sebagai objek, yang bakal kita sebut action. Komponen boleh bikin action kaya gitu dan dispatch mereka, ngasih mereka ke fungsi manajemen state pusat. Fungsi itu ngitung state berikutnya, setelah itu komponen interface ngupdate diri mereka ke state baru ini.
Kita ngambil tugas berantakan menjalankan interface pengguna dan menerapkan struktur ke dia. Walaupun potongan terkait-DOM masih penuh side effect, mereka ditopang oleh tulang punggung yang secara konseptual sederhana: siklus update state. State-nya nentuin seperti apa DOM keliatan, dan satu-satunya cara event DOM bisa ngubah state adalah dengan men-dispatch action ke state.
Ada banyak varian pendekatan ini, masing-masing dengan manfaat dan masalahnya sendiri, tapi ide sentral mereka sama: perubahan state harus lewat satu saluran yang terdefinisi dengan baik, gak terjadi di mana-mana.
Komponen kita bakal jadi class yang memenuhi interface. Constructor mereka dikasih state (yang bisa jadi seluruh state aplikasi atau nilai yang lebih kecil kalau dia gak butuh akses ke semuanya) dan pakai itu buat ngebangun properti dom. Ini elemen DOM yang mewakili komponennya. Kebanyakan constructor juga bakal ngambil beberapa nilai lain yang gak bakal berubah seiring waktu, kaya fungsi yang bisa mereka pakai buat men-dispatch action.
Setiap komponen punya method syncState yang dipakai buat menyinkronkan dia ke nilai state baru. Method-nya ngambil satu argumen, state-nya, yang tipenya sama dengan argumen pertama ke constructor-nya.
State
State aplikasinya bakal jadi objek dengan properti picture, tool, dan color. Picture-nya sendiri objek yang nyimpen lebar, tinggi, dan isi piksel gambarnya. Piksel-piksel disimpan di satu array, baris demi baris, dari atas ke bawah.
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);
}
}Kita mau bisa memperlakukan picture sebagai nilai immutable, karena alasan yang bakal kita balik lagi nanti di bab. Tapi kita juga kadang perlu ngupdate sekumpulan piksel sekaligus. Biar bisa ngelakuin itu, class-nya punya method draw yang ngarepin array piksel yang diupdate (objek dengan properti x, y, dan color) dan bikin picture baru dengan piksel itu ditimpa. Method ini pakai slice tanpa argumen buat nyalin seluruh array piksel, awal slice-nya default ke 0, dan akhirnya default ke panjang array-nya.
Method empty pakai dua fungsionalitas array yang belum kita lihat sebelumnya. Constructor Array bisa dipanggil dengan angka buat bikin array kosong dengan panjang yang dikasih. Method fill lalu bisa dipakai buat ngisi array ini dengan nilai yang dikasih. Ini dipakai buat bikin array di mana semua piksel punya warna yang sama.
Warna disimpan sebagai string yang berisi kode warna CSS tradisional yang terdiri dari tanda hash (#) diikuti enam digit heksadesimal (basis 16), dua buat komponen merah, dua buat komponen hijau, dan dua buat komponen biru. Ini cara yang agak samar dan gak praktis buat nulis warna, tapi dia format yang dipakai field input warna HTML, dan dia bisa dipakai di properti fillStyle konteks menggambar canvas, jadi buat cara kita bakal pakai warna di program ini, dia cukup praktis.
Hitam, di mana semua komponen nol, ditulis "#000000", dan pink terang keliatan kaya "#ff00ff", di mana komponen merah dan biru punya nilai maksimum 255, ditulis ff dalam digit heksadesimal (yang pakai a sampai f buat mewakili digit 10 sampai 15).
Kita bakal ngebolehin interface men-dispatch action sebagai objek yang propertinya nimpa properti state sebelumnya. Field warna, waktu pengguna ngubahnya, bisa men-dispatch objek kaya {color: field.value}, dari mana fungsi update ini bisa ngitung state baru.
function updateState(state, action) {
return {...state, ...action};
}Pola ini, di mana object spread dipakai buat pertama nambahin properti dari objek yang ada lalu nimpa beberapa dari mereka, umum di kode JavaScript yang pakai objek immutable.
Membangun DOM
Salah satu hal utama yang dilakuin komponen interface adalah bikin struktur DOM. Kita lagi-lagi gak mau langsung pakai method DOM yang bertele-tele buat itu, jadi ini versi yang sedikit diperluas dari fungsi 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;
}Perbedaan utama antara versi ini dan yang kita pakai di Bab 16 adalah dia nge-assign properti ke node DOM, bukan atribut. Ini berarti kita gak bisa pakai dia buat nyetel atribut sembarangan, tapi kita bisa pakai dia buat nyetel properti yang nilainya bukan string, kaya onclick, yang bisa disetel ke fungsi buat mendaftarkan event handler klik.
Ini ngebolehin gaya praktis ini buat mendaftarkan event handler:
<body>
<script>
document.body.appendChild(elt("button", {
onclick: () => console.log("click")
}, "The button"));
</script>
</body>Canvas
Komponen pertama yang bakal kita definisikan adalah bagian interface yang nampilin gambar sebagai grid kotak berwarna. Komponen ini bertanggung jawab atas dua hal: nampilin gambar dan mengomunikasikan event pointer di gambar itu ke sisa aplikasi.
Jadi, kita bisa mendefinisikannya sebagai komponen yang cuma tahu soal gambar saat ini, bukan seluruh state aplikasi. Karena dia gak tahu gimana aplikasi secara keseluruhan bekerja, dia gak bisa langsung men-dispatch action. Sebaliknya, waktu merespons event pointer, dia manggil fungsi callback yang disediain kode yang bikin dia, yang bakal nanganin bagian spesifik-aplikasi.
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);
}
}Kita menggambar setiap piksel sebagai kotak 10 kali 10, kaya yang ditentuin konstanta scale. Buat ngehindarin kerja yang gak perlu, komponennya ngelacak gambar saat ini dan cuma ngelakuin redraw waktu syncState dikasih gambar baru.
Fungsi menggambar sebenarnya nyetel ukuran canvas-nya berdasarkan skala dan ukuran gambar dan ngisinya dengan deretan kotak, satu buat setiap piksel.
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);
}
}
}Waktu tombol mouse kiri ditekan sementara mouse-nya di atas canvas gambar, komponennya manggil callback pointerDown, ngasihnya posisi piksel yang diklik, dalam koordinat gambar. Ini bakal dipakai buat ngimplementasiin interaksi mouse dengan gambar. Callback-nya boleh ngembaliin fungsi callback lain buat diberi tahu waktu pointer-nya dipindahin ke piksel berbeda sementara tombolnya ditahan.
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 {
let 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)};
}Karena kita tahu ukuran piksel-nya dan kita bisa pakai getBoundingClientRect buat nemuin posisi canvas di layar, bisa aja pergi dari koordinat event mouse (clientX dan clientY) ke koordinat gambar. Ini selalu dibulatkan ke bawah biar mereka ngerujuk ke piksel tertentu.
Dengan event touch, kita harus ngelakuin sesuatu yang serupa, tapi pakai event berbeda dan mastiin kita manggil preventDefault di event "touchstart" buat nyegah panning.
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("touchend", end);
};
this.dom.addEventListener("touchmove", move);
this.dom.addEventListener("touchend", end);
};Buat event touch, clientX dan clientY gak tersedia langsung di event object, tapi kita bisa pakai koordinat objek touch pertama di properti touches.
Aplikasi
Biar mungkin ngebangun aplikasinya potongan demi potongan, kita bakal ngimplementasiin komponen utama sebagai cangkang di sekitar canvas gambar dan sekumpulan tool dan kontrol dinamis yang kita kasih ke constructor-nya.
Controls adalah elemen interface yang muncul di bawah gambar. Mereka bakal disediain sebagai array constructor komponen.
Tools ngelakuin hal kaya menggambar piksel atau ngisi area. Aplikasi nunjukin set tool yang tersedia sebagai field <select>. Tool yang saat ini terpilih nentuin apa yang terjadi waktu pengguna berinteraksi dengan gambar pakai perangkat pointer. Set tool yang tersedia disediain sebagai objek yang memetakan nama yang muncul di field dropdown ke fungsi yang ngimplementasiin tool-nya. Fungsi kaya gitu dapet posisi gambar, state aplikasi saat ini, dan fungsi dispatch sebagai argumen. Mereka boleh ngembaliin fungsi move handler yang dipanggil dengan posisi baru dan state saat ini waktu pointer-nya pindah ke piksel berbeda.
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);
}
}Pointer handler yang dikasih ke PictureCanvas manggil tool yang saat ini terpilih dengan argumen yang tepat dan, kalau itu ngembaliin move handler, mengadaptasinya buat juga nerima state.
Semua kontrol dikonstruksi dan disimpan di this.controls biar mereka bisa diupdate waktu state aplikasi berubah. Pemanggilan reduce ngenalin spasi di antara elemen DOM kontrol. Dengan cara itu, mereka gak keliatan begitu berdesakan.
Kontrol pertama adalah menu seleksi tool. Dia bikin elemen <select> dengan opsi buat setiap tool dan nyiapin event handler "change" yang ngupdate state aplikasi waktu pengguna milih tool berbeda.
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; }
}Dengan ngebungkus teks label dan field-nya dalam elemen <label>, kita ngasih tahu browser bahwa label-nya termasuk field itu biar kamu bisa, misalnya, ngeklik label buat memfokus field-nya.
Kita juga perlu bisa ngubah warna, jadi kita tambahin kontrol buat itu. Elemen HTML <input> dengan atribut type color ngasih kita field form yang dikhususkan buat milih warna. Nilai field kaya gitu selalu kode warna CSS dalam format "#RRGGBB" (komponen merah, hijau, dan biru, dua digit per warna). Browser bakal nunjukin interface color picker waktu pengguna berinteraksi dengannya.
Kontrol ini bikin field kaya gitu dan nyambungin dia biar tetap tersinkronisasi dengan properti color state aplikasi.
class ColorSelect {
constructor(state, {dispatch}) {
this.input = elt("input", {
type: "color",
value: state.color,
onchange: () => dispatch({color: this.input.value})
});
this.dom = elt("label", null, "🎨 Color: ", this.input);
}
syncState(state) { this.input.value = state.color; }
}Tool Gambar
Sebelum kita bisa menggambar apa pun, kita perlu ngimplementasiin tool yang bakal ngendaliin fungsionalitas event mouse atau touch di canvas.
Tool paling dasar adalah tool draw, yang ngubah piksel apa pun yang kamu klik atau ketuk jadi warna yang saat ini terpilih. Dia men-dispatch action yang ngupdate gambar ke versi di mana piksel yang ditunjuk dikasih warna yang saat ini terpilih.
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;
}Fungsinya langsung manggil fungsi drawPixel tapi lalu juga ngembaliin dia biar dia dipanggil lagi buat piksel yang baru disentuh waktu pengguna menyeret atau menggesek di seluruh gambar.
Buat menggambar shape yang lebih besar, bisa berguna buat cepat bikin persegi panjang. Tool rectangle menggambar persegi panjang antara titik tempat kamu mulai menyeret dan titik tempat kamu menyeret.
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;
}Detail penting di implementasi ini adalah waktu menyeret, persegi panjangnya digambar ulang di gambar dari state asli. Dengan cara itu, kamu bisa bikin persegi panjangnya lebih besar dan lebih kecil lagi sambil bikinnya, tanpa persegi panjang perantaranya nempel di gambar akhir. Ini salah satu alasan kenapa objek picture immutable berguna, kita bakal lihat alasan lain nanti.
Ngimplementasiin flood fill agak lebih rumit. Ini tool yang ngisi piksel di bawah pointer dan semua piksel bersebelahan yang punya warna yang sama. “Bersebelahan” berarti bersebelahan langsung secara horizontal atau vertikal, bukan diagonal. Gambar ini ngilustrasiin set piksel yang diwarnai waktu tool flood fill dipakai di piksel yang ditandai:
Menariknya, cara kita bakal ngelakuin ini keliatan agak mirip kode pathfinding dari Bab 7. Sementara kode itu nyari lewat graph buat nemuin rute, kode ini nyari lewat grid buat nemuin semua piksel yang “terhubung”. Masalah ngelacak set rute yang mungkin bercabang itu serupa.
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) &&
state.picture.pixel(x, y) == targetColor) {
drawn.push({x, y, color: state.color});
visited.add(x + "," + y);
}
}
}
dispatch({picture: state.picture.draw(drawn)});
}Array piksel yang digambar berperan ganda sebagai work list fungsinya. Buat setiap piksel yang dicapai, kita harus lihat apakah ada piksel bersebelahan yang punya warna yang sama dan belum diwarnai. Counter loop-nya ketinggalan di belakang panjang array drawn seiring piksel baru ditambahin. Piksel apa pun di depannya masih perlu dieksplorasi. Waktu dia nyusul panjangnya, gak ada piksel yang belum dieksplorasi tersisa, dan fungsinya selesai.
Tool terakhir adalah color picker, yang ngebolehin kamu nunjuk ke warna di gambar buat pakainya sebagai warna menggambar saat ini.
function pick(pos, state, dispatch) {
dispatch({color: state.picture.pixel(pos.x, pos.y)});
}Kita sekarang bisa ngetes aplikasi kita!
<div></div>
<script>
let state = {
tool: "draw",
color: "#000000",
picture: 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>Menyimpan dan Memuat
Waktu kita udah menggambar mahakarya kita, kita bakal mau nyimpennya buat nanti. Kita harus nambahin tombol buat mengunduh gambar saat ini sebagai file gambar. Kontrol ini nyediain tombol itu:
class SaveButton {
constructor(state) {
this.picture = state.picture;
this.dom = elt("button", {
onclick: () => this.save()
}, "💾 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; }
}Komponennya ngelacak gambar saat ini biar dia bisa ngaksesnya waktu nyimpen. Buat bikin file gambarnya, dia pakai elemen <canvas> tempat dia menggambar gambarnya (di skala satu piksel per piksel).
Method toDataURL di elemen canvas bikin URL yang pakai skema data:. Berbeda dari URL http: dan https:, URL data berisi seluruh resource di URL-nya. Mereka biasanya sangat panjang, tapi mereka ngebolehin kita bikin link yang berfungsi ke gambar sembarangan, langsung di sini di browser.
Buat beneran bikin browser mengunduh gambarnya, kita lalu bikin elemen link yang nunjuk ke URL ini dan punya atribut download. Link kaya gitu, waktu diklik, bikin browser nunjukin dialog simpan file. Kita nambahin link itu ke dokumen, nyimulasiin klik di dia, dan ngehapusnya lagi. Kamu bisa ngelakuin banyak hal dengan teknologi browser, tapi kadang cara ngelakuinnya agak aneh.
Dan dia jadi lebih buruk. Kita juga bakal mau bisa memuat file gambar yang udah ada ke aplikasi kita. Buat ngelakuin itu, kita lagi mendefinisikan komponen tombol.
class LoadButton {
constructor(_, {dispatch}) {
this.dom = elt("button", {
onclick: () => startLoad(dispatch)
}, "📁 Load");
}
syncState() {}
}
function startLoad(dispatch) {
let input = elt("input", {
type: "file",
onchange: () => finishLoad(input.files[0], dispatch)
});
document.body.appendChild(input);
input.click();
input.remove();
}Buat dapet akses ke file di komputer pengguna, kita perlu pengguna milih file lewat field input file. Tapi kita gak mau tombol load-nya keliatan kaya field input file, jadi kita bikin input file waktu tombolnya diklik lalu pura-pura input file ini sendiri diklik.
Waktu pengguna udah milih file, kita bisa pakai FileReader buat dapet akses ke isinya, lagi sebagai URL data. URL itu bisa dipakai buat bikin elemen <img>, tapi karena kita gak bisa dapet akses langsung ke piksel di gambar kaya gitu, kita gak bisa bikin objek Picture dari itu.
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);
}Buat dapet akses ke piksel-nya, kita harus pertama menggambar gambarnya ke elemen <canvas>. Konteks canvas punya method getImageData yang ngebolehin script baca piksel-nya. Jadi begitu gambarnya di canvas, kita bisa ngaksesnya dan nyusun objek 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);
}Kita bakal ngebatasin ukuran gambar ke 100 kali 100 piksel, karena apa pun yang lebih besar bakal keliatan raksasa di tampilan kita dan mungkin memperlambat interface-nya.
Properti data dari objek yang dikembaliin getImageData adalah array komponen warna. Buat setiap piksel di persegi panjang yang ditentuin argumennya, dia berisi empat nilai yang mewakili komponen merah, hijau, biru, dan alpha dari warna piksel, sebagai angka antara 0 dan 255. Bagian alpha mewakili opasitas, waktu dia 0, piksel-nya sepenuhnya transparan, dan waktu dia 255, dia sepenuhnya buram. Buat tujuan kita, kita bisa ngabaikannya.
Dua digit heksadesimal per komponen, kaya yang dipakai di notasi warna kita, sesuai persis dengan rentang 0 sampai 255, dua digit basis-16 bisa nyatain 16^2 = 256 angka berbeda. Method toString dari angka bisa dikasih basis sebagai argumen, jadi n.toString(16) bakal ngehasilin representasi string dalam basis 16. Kita harus mastiin setiap angka makan dua digit, jadi fungsi pembantu hex manggil padStart buat nambahin 0 di depan waktu perlu.
Kita bisa memuat dan nyimpen sekarang! Itu ninggalin cuma satu fitur lagi sebelum kita selesai.
Undo History
Karena separuh proses mengedit adalah bikin kesalahan kecil dan mengoreksinya, fitur penting di program menggambar adalah undo history.
Biar bisa mengurungkan (undo) perubahan, kita perlu nyimpen versi sebelumnya dari gambar. Karena gambar itu nilai immutable, itu gampang. Tapi dia emang butuh field tambahan di state aplikasi.
Kita bakal nambahin array done buat ngejaga versi sebelumnya dari gambar. Memelihara properti ini butuh fungsi update state yang lebih rumit yang nambahin gambar ke array-nya.
Kita gak mau nyimpen setiap perubahan, tapi cuma perubahan yang terpisah jumlah waktu tertentu. Biar bisa ngelakuin itu, kita bakal butuh properti kedua, doneAt, buat ngelacak waktu terakhir kita nyimpen gambar di history.
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) {
return {
...state,
...action,
done: [state.picture, ...state.done],
doneAt: Date.now()
};
} else {
return {...state, ...action};
}
}Waktu action-nya action undo, fungsinya ngambil gambar terbaru dari history dan bikin itu jadi gambar saat ini. Dia nyetel doneAt ke nol biar perubahan berikutnya dijamin nyimpen gambarnya balik di history, ngebolehin kamu balik ke dia lain kali kalau kamu mau.
Kalau gak, kalau action-nya berisi gambar baru dan terakhir kali kita nyimpen sesuatu lebih dari satu detik (1000 milidetik) yang lalu, properti done dan doneAt diupdate buat nyimpen gambar sebelumnya.
Komponen tombol undo gak ngelakuin banyak. Dia men-dispatch action undo waktu diklik dan menonaktifkan dirinya waktu gak ada yang bisa di-undo.
class UndoButton {
constructor(state, {dispatch}) {
this.dom = elt("button", {
onclick: () => dispatch({undo: true}),
disabled: state.done.length == 0
}, "⮪ Undo");
}
syncState(state) {
this.dom.disabled = state.done.length == 0;
}
}Ayo Menggambar
Buat nyiapin aplikasinya, kita perlu bikin state, sekumpulan tool, sekumpulan kontrol, dan fungsi dispatch. Kita bisa ngasih mereka ke constructor PixelEditor buat bikin komponen utama. Karena kita bakal perlu bikin beberapa editor di latihan, kita pertama mendefinisikan beberapa binding.
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;
}Waktu men-destructure objek atau array, kamu bisa pakai = setelah nama binding buat ngasih binding-nya nilai default, yang dipakai waktu propertinya hilang atau megang undefined. Fungsi startPixelEditor memanfaatkan ini buat nerima objek dengan sejumlah properti opsional sebagai argumen. Kalau kamu gak nyediain properti tools, misalnya, tools bakal terikat ke baseTools.
Ini cara kita dapet editor beneran di layar:
<div></div>
<script>
document.querySelector("div")
.appendChild(startPixelEditor({}));
</script>Silakan menggambar sesuatu.
Kenapa Ini Begitu Susah?
Teknologi browser itu menakjubkan. Dia nyediain sekumpulan blok bangunan interface yang kuat, cara buat nge-style dan memanipulasi mereka, dan alat buat meriksa dan men-debug aplikasimu. Software yang kamu tulis buat browser bisa dijalanin di hampir setiap komputer dan ponsel di planet ini.
Di saat yang sama, teknologi browser itu konyol. Kamu harus belajar sejumlah besar trik konyol dan fakta samar buat menguasainya, dan model pemrograman default yang dia sediain begitu bermasalah sampai kebanyakan programmer lebih suka menutupinya dalam beberapa lapisan abstraksi daripada menghadapinya langsung.
Walaupun situasinya jelas membaik, dia kebanyakan ngelakuinnya dalam bentuk lebih banyak elemen ditambahin buat ngatasin kekurangan, bikin lebih banyak kerumitan lagi. Fitur yang dipakai sejuta website gak beneran bisa diganti. Bahkan kalau dia bisa, bakal susah mutusin dia harus diganti dengan apa.
Teknologi gak pernah ada dalam ruang hampa, kita dibatasi oleh alat kita dan faktor sosial, ekonomi, dan sejarah yang menghasilkan mereka. Ini bisa menyebalkan, tapi umumnya lebih produktif buat nyoba ngebangun pemahaman yang baik soal gimana realitas teknis yang ada bekerja, dan kenapa dia jadi begitu, daripada marah padanya atau nunggu realitas lain.
Abstraksi baru bisa membantu. Model komponen dan konvensi aliran data yang saya pakai di bab ini adalah bentuk kasar dari itu. Kaya yang disebutin, ada library yang nyoba bikin pemrograman interface pengguna lebih menyenangkan. Saat tulisan ini dibuat, React dan Svelte adalah pilihan populer, tapi ada seluruh industri rumahan framework kaya gitu. Kalau kamu tertarik memprogram aplikasi web, saya nyaranin nyelidikin beberapa dari mereka buat paham cara mereka bekerja dan manfaat apa yang mereka sediain.
Ringkasan
- Editor disusun sebagai komponen (class dengan
dom+syncState) dan aliran data satu arah: ada satu state, interface digambar dari state, event men-dispatch action ke fungsi update pusat, lalu komponen menyinkron ke state baru. Pictureitu nilai immutable (methoddrawbikin picture baru). Ini bikin undo gampang, rectangle bisa digambar ulang dari state awal, dan drawing bisa dioptimasi (bandingin lama vs baru).PictureCanvascuma tahu gambar + callback pointer (mouse & touch), gak tahu seluruh aplikasi.- Tool = fungsi
(pos, state, dispatch)yang boleh ngembaliin move handler:draw,rectangle,fill(flood fill mirip pathfinding),pick. - Save:
canvas.toDataURL()+ linkdownload. Load: input file +FileReader+getImageDatabuat baca piksel. - Undo: simpan versi lama di array
done, dibatasi 1 detik sekali (doneAt).
Latihan
Keyboard Binding
Tambahin keyboard shortcut ke aplikasi. Huruf pertama dari nama tool milih tool-nya, dan ctrl-Z atau command-Z ngaktifin undo.
Lakuin ini dengan memodifikasi komponen PixelEditor. Tambahin properti tabIndex 0 ke elemen <div> pembungkusnya biar dia bisa nerima keyboard focus. Perhatiin bahwa properti yang sesuai dengan atribut tabindex disebut tabIndex, dengan I besar, dan fungsi elt kita ngarepin nama properti. Daftarkan event handler tombol langsung di elemen itu. Ini berarti kamu harus klik, sentuh, atau tab ke aplikasinya sebelum kamu bisa berinteraksi dengannya pakai keyboard.
Ingat bahwa event keyboard punya properti ctrlKey dan metaKey (buat command di Mac) yang bisa kamu pakai buat lihat apakah tombol-tombol itu ditahan.
<div></div>
<script>
// Class PixelEditor asli. Perluas constructor-nya.
// ... (lihat kode bab)
</script>Petunjuk:
Properti key dari event buat tombol huruf bakal jadi huruf kecil itu sendiri, kalau shift gak ditahan. Kita gak tertarik sama event tombol dengan shift di sini.
Handler "keydown" bisa meriksa event object-nya buat lihat apakah dia cocok dengan salah satu shortcut. Kamu bisa otomatis dapet daftar huruf pertama dari objek tools biar kamu gak harus nulis mereka.
Waktu event tombolnya cocok dengan shortcut, manggil preventDefault di dia dan dispatch action yang tepat.
Solusi:
<div></div>
<script>
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", {
tabIndex: 0,
onkeydown: event => this.keyDown(event, config)
}, this.canvas.dom, elt("br"),
...this.controls.reduce(
(a, c) => a.concat(" ", c.dom), []));
}
keyDown(event, config) {
if (event.key == "z" && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
config.dispatch({undo: true});
} else if (!event.ctrlKey && !event.metaKey && !event.altKey) {
for (let tool of Object.keys(config.tools)) {
if (tool[0] == event.key) {
event.preventDefault();
config.dispatch({tool});
return;
}
}
}
}
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>Menggambar yang Efisien
Selama menggambar, mayoritas kerja yang dilakuin aplikasi kita terjadi di drawPicture. Bikin state baru dan ngupdate sisa DOM gak terlalu mahal, tapi menggambar ulang semua piksel di canvas itu cukup banyak kerja.
Nemuin cara buat bikin method syncState dari PictureCanvas lebih cepat dengan cuma menggambar ulang piksel yang beneran berubah.
Ingat bahwa drawPicture juga dipakai tombol save, jadi kalau kamu ngubahnya, entah mastiin perubahannya gak ngerusak penggunaan lama atau bikin versi baru dengan nama berbeda.
Juga perhatiin bahwa ngubah ukuran elemen <canvas>, dengan nyetel properti width atau height-nya, ngebersihinnya, bikinnya sepenuhnya transparan lagi.
Petunjuk:
Latihan ini contoh bagus gimana struktur data immutable bisa bikin kode lebih cepat. Karena kita punya gambar lama maupun baru, kita bisa ngebandingin mereka dan cuma menggambar ulang piksel yang berubah warna, ngehemat lebih dari 99 persen kerja menggambar di kebanyakan kasus.
Kamu bisa nulis fungsi baru updatePicture atau bikin drawPicture ngambil argumen ekstra, yang bisa undefined atau gambar sebelumnya. Buat setiap piksel, fungsinya ngecek apakah gambar sebelumnya dikasih dengan warna yang sama di posisi ini dan ngelewatin piksel-nya waktu itu kasusnya.
Karena canvas-nya jadi kosong waktu kita ngubah ukurannya, kamu juga harus ngehindarin nyentuh properti width dan height-nya waktu gambar lama dan gambar baru punya ukuran yang sama.
Solusi:
<div></div>
<script>
PictureCanvas.prototype.syncState = function(picture) {
if (this.picture == picture) return;
drawPicture(picture, this.dom, scale, this.picture);
this.picture = picture;
}
function drawPicture(picture, canvas, scale, previous) {
if (previous == null ||
previous.width != picture.width ||
previous.height != picture.height) {
canvas.width = picture.width * scale;
canvas.height = picture.height * scale;
previous = null;
}
let cx = canvas.getContext("2d");
for (let y = 0; y < picture.height; y++) {
for (let x = 0; x < picture.width; x++) {
let color = picture.pixel(x, y);
if (previous == null || previous.pixel(x, y) != color) {
cx.fillStyle = color;
cx.fillRect(x * scale, y * scale, scale, scale);
}
}
}
}
document.querySelector("div")
.appendChild(startPixelEditor({}));
</script>Lingkaran
Definisikan tool bernama circle yang menggambar lingkaran terisi waktu kamu menyeret. Pusat lingkarannya terletak di titik tempat gestur seret atau touch mulai, dan radiusnya ditentuin jarak yang diseret.
<div></div>
<script>
function circle(pos, state, dispatch) {
// Kodemu di sini
}
let dom = startPixelEditor({
tools: {...baseTools, circle}
});
document.querySelector("div").appendChild(dom);
</script>Petunjuk:
Kamu bisa ngambil inspirasi dari tool rectangle. Kaya tool itu, kamu bakal mau terus menggambar di gambar awal, bukan gambar saat ini, waktu pointer-nya bergerak.
Buat nyari tahu piksel mana yang diwarnai, kamu bisa pakai teorema Pythagoras. Pertama nyari tahu jarak antara posisi pointer saat ini dan posisi awal dengan ngambil akar kuadrat (Math.sqrt) dari jumlah kuadrat (x ** 2) selisih koordinat x dan kuadrat selisih koordinat y. Lalu ngeloop persegi piksel di sekitar posisi awal, yang sisinya minimal dua kali radius, dan warnai yang di dalam radius lingkaran, lagi pakai rumus Pythagoras buat nyari tahu jarak mereka dari pusat.
Pastiin kamu gak nyoba mewarnai piksel yang di luar batas gambar.
Solusi:
<div></div>
<script>
function circle(pos, state, dispatch) {
function drawCircle(to) {
let radius = Math.sqrt((to.x - pos.x) ** 2 +
(to.y - pos.y) ** 2);
let radiusC = Math.ceil(radius);
let drawn = [];
for (let dy = -radiusC; dy <= radiusC; dy++) {
for (let dx = -radiusC; dx <= radiusC; dx++) {
let dist = Math.sqrt(dx ** 2 + dy ** 2);
if (dist > radius) continue;
let y = pos.y + dy, x = pos.x + dx;
if (y < 0 || y >= state.picture.height ||
x < 0 || x >= state.picture.width) continue;
drawn.push({x, y, color: state.color});
}
}
dispatch({picture: state.picture.draw(drawn)});
}
drawCircle(pos);
return drawCircle;
}
let dom = startPixelEditor({
tools: Object.assign({}, baseTools, {circle})
});
document.querySelector("div").appendChild(dom);
</script>Garis yang Benar
Ini latihan yang lebih lanjut dari tiga sebelumnya, dan dia bakal ngeharusin kamu ngedesain solusi buat masalah yang nontrivial. Pastiin kamu punya banyak waktu dan kesabaran sebelum mulai ngerjain latihan ini, dan jangan patah semangat oleh kegagalan awal.
Di kebanyakan browser, waktu kamu milih tool draw dan cepat menyeret di seluruh gambar, kamu gak dapet garis tertutup. Sebaliknya, kamu dapet titik dengan celah di antaranya karena event "mousemove" atau "touchmove" gak nembak cukup cepat buat kena setiap piksel.
Perbaiki tool draw buat bikinnya menggambar garis penuh. Ini berarti kamu harus bikin fungsi motion handler ngingat posisi sebelumnya dan nyambungin itu ke yang sekarang.
Buat ngelakuin ini, karena piksel-nya bisa terpisah jarak sembarangan, kamu bakal harus nulis fungsi menggambar garis umum.
Garis antara dua piksel adalah rantai piksel yang terhubung, selurus mungkin, pergi dari awal ke akhir. Piksel yang bersebelahan diagonal dihitung sebagai terhubung.
Terakhir, kalau kita punya kode yang menggambar garis antara dua titik sembarang, kita sekalian pakai dia buat juga mendefinisikan tool line, yang menggambar garis lurus antara awal dan akhir seretan.
<div></div>
<script>
// ... (lihat kode bab)
</script>Petunjuk:
Hal soal masalah menggambar garis piksel adalah dia sebenernya empat masalah yang mirip tapi sedikit berbeda. Menggambar garis horizontal dari kiri ke kanan itu gampang, kamu ngeloop koordinat x dan mewarnai piksel di setiap langkah. Kalau garisnya punya kemiringan sedikit (kurang dari 45 derajat atau ¼π radian), kamu bisa menginterpolasi koordinat y sepanjang kemiringannya. Kamu masih butuh satu piksel per posisi x, dengan posisi y piksel itu ditentuin kemiringannya.
Tapi begitu kemiringanmu melintasi 45 derajat, kamu perlu ngeganti cara kamu memperlakukan koordinatnya. Kamu sekarang butuh satu piksel per posisi y, karena garisnya naik lebih banyak dari dia pergi ke kiri. Lalu, waktu kamu melintasi 135 derajat, kamu harus balik ke ngeloop koordinat x, tapi dari kanan ke kiri.
Kamu sebenernya gak harus nulis empat loop. Karena menggambar garis dari A ke B sama dengan menggambar garis dari B ke A, kamu bisa nuker posisi awal dan akhir buat garis yang pergi dari kanan ke kiri dan memperlakukan mereka sebagai pergi dari kiri ke kanan.
Jadi kamu butuh dua loop berbeda. Hal pertama yang harus dilakuin fungsi menggambar garismu adalah ngecek apakah selisih antara koordinat x lebih besar dari selisih antara koordinat y. Kalau iya, ini garis agak-horizontal, dan kalau gak, agak-vertikal. Pastiin kamu ngebandingin nilai absolut dari selisih x dan y, yang bisa kamu dapet dengan Math.abs.
Cara ringkas nuker nilai dua binding di JavaScript pakai destructuring assignment kaya gini: [start, end] = [end, start];. Lalu kamu bisa ngitung kemiringan garisnya, dan menggambar piksel di setiap iterasi. Pastiin kamu ngebulatin koordinat sumbu non-utama, karena mereka kemungkinan pecahan.
Solusi:
<div></div>
<script>
function drawLine(from, to, color) {
let points = [];
if (Math.abs(from.x - to.x) > Math.abs(from.y - to.y)) {
if (from.x > to.x) [from, to] = [to, from];
let slope = (to.y - from.y) / (to.x - from.x);
for (let {x, y} = from; x <= to.x; x++) {
points.push({x, y: Math.round(y), color});
y += slope;
}
} else {
if (from.y > to.y) [from, to] = [to, from];
let slope = (to.x - from.x) / (to.y - from.y);
for (let {x, y} = from; y <= to.y; y++) {
points.push({x: Math.round(x), y, color});
x += slope;
}
}
return points;
}
function draw(pos, state, dispatch) {
function connect(newPos, state) {
let line = drawLine(pos, newPos, state.color);
pos = newPos;
dispatch({picture: state.picture.draw(line)});
}
connect(pos, state);
return connect;
}
function line(pos, state, dispatch) {
return end => {
let line = drawLine(pos, end, state.color);
dispatch({picture: state.picture.draw(line)});
};
}
let dom = startPixelEditor({
tools: {draw, line, fill, rectangle, pick}
});
document.querySelector("div").appendChild(dom);
</script>