十九胞得、項目:像素藝術(shù)編輯器
原文:Project: A Pixel Art Editor
譯者:飛龍
協(xié)議:CC BY-NC-SA 4.0
自豪地采用谷歌翻譯
我看著眼前的許多顏色痊远。 我看著我的空白畫布。 然后匾效,我嘗試使用顏色,就像形成詩歌的詞語熊锭,就像塑造音樂的音符弧轧。
Joan Miro
https://raw.githubusercontent.com/wizardforcel/eloquent-js-3e-zh/master/img/19-0.jpg
前面幾章的內(nèi)容為你提供了構(gòu)建基本的 Web 應(yīng)用所需的所有元素雪侥。 在本章中碗殷,我們將實現(xiàn)一個。
我們的應(yīng)用將是像素繪圖程序速缨,你可以通過操縱放大視圖(正方形彩色網(wǎng)格)锌妻,來逐像素修改圖像。 你可以使用它來打開圖像文件旬牲,用鼠標(biāo)或其他指針設(shè)備在它們上面涂畫并保存仿粹。 這是它的樣子:
https://raw.githubusercontent.com/wizardforcel/eloquent-js-3e-zh/master/img/19-1.png
在電腦上繪畫很棒。 你不需要擔(dān)心材料原茅,技能或天賦吭历。 你只需要開始涂畫。
組件
應(yīng)用的界面在頂部顯示大的<canvas>
元素擂橘,在它下面有許多表單字段晌区。 用戶通過從<select>
字段中選擇工具,然后單擊,觸摸或拖動畫布來繪制圖片朗若。 有用于繪制單個像素或矩形恼五,填充區(qū)域以及從圖片中選取顏色的工具。
我們將編輯器界面構(gòu)建為多個組件和對象哭懈,負(fù)責(zé) DOM 的一部分灾馒,并可能在其中包含其他組件。
應(yīng)用的狀態(tài)由當(dāng)前圖片遣总,所選工具和所選顏色組成睬罗。 我們將建立一些東西,以便狀態(tài)存在于單一的值中彤避,并且界面組件總是基于當(dāng)前狀態(tài)下他們看上去的樣子傅物。
為了明白為什么這很重要,讓我們考慮替代方案:將狀態(tài)片段分配給整個界面琉预。 直到某個時期董饰,這更容易編寫。 我們可以放入顏色字段圆米,并在需要知道當(dāng)前顏色時讀取其值卒暂。
但是,我們添加了顏色選擇器娄帖。它是一種工具也祠,可讓你單擊圖片來選擇給定像素的顏色。 為了保持顏色字段顯示正確的顏色近速,該工具必須知道它存在诈嘿,并在每次選擇新顏色時對其進(jìn)行更新。 如果你添加了另一個讓顏色可見的地方(也許鼠標(biāo)光標(biāo)可以顯示它)削葱,你必須更新你的改變顏色的代碼來保持同步奖亚。
實際上,這會讓你遇到一個問題析砸,即界面的每個部分都需要知道所有其他部分昔字,它們并不是非常模塊化的。 對于本章中的小應(yīng)用首繁,這可能不成問題作郭。 對于更大的項目,它可能變成真正的噩夢弦疮。
所以為了在原則上避免這種噩夢夹攒,我們將對數(shù)據(jù)流非常嚴(yán)格。 存在一個狀態(tài)胁塞,界面根據(jù)該狀態(tài)繪制咏尝。 界面組件可以通過更新狀態(tài)來響應(yīng)用戶動作堂湖,此時組件有機(jī)會與新的狀態(tài)進(jìn)行同步。
在實踐中状土,每個組件的建立无蜂,都是為了在給定一個新的狀態(tài)時,它還會通知它的子組件蒙谓,只要這些組件需要更新斥季。 建立這個有點麻煩。 讓這個更方便是許多瀏覽器編程庫的主要賣點累驮。 但對于像這樣的小應(yīng)用酣倾,我們可以在沒有這種基礎(chǔ)設(shè)施的情況下完成。
狀態(tài)更新表示為對象谤专,我們將其稱為動作躁锡。 組件可以創(chuàng)建這樣的動作并分派它們 - 將它們給予中央狀態(tài)管理函數(shù)。 該函數(shù)計算下一個狀態(tài)置侍,之后界面組件將自己更新為這個新狀態(tài)映之。
我們正在執(zhí)行一個混亂的任務(wù),運(yùn)行一個用戶界面并對其應(yīng)用一些結(jié)構(gòu)蜡坊。 盡管與 DOM 相關(guān)的部分仍然充滿了副作用杠输,但它們由一個概念上簡單的主干支撐 - 狀態(tài)更新循環(huán)。 狀態(tài)決定了 DOM 的外觀秕衙,而 DOM 事件可以改變狀態(tài)的唯一方法蠢甲,是向狀態(tài)分派動作。
這種方法有許多變種据忘,每個變種都有自己的好處和問題鹦牛,但它們的中心思想是一樣的:狀態(tài)變化應(yīng)該通過明確定義的渠道,而不是遍布整個地方勇吊。
我們的組件將是與界面一致的類曼追。 他們的構(gòu)造器被賦予一個狀態(tài),它可能是整個應(yīng)用狀態(tài)萧福,或者如果它不需要訪問所有東西拉鹃,是一些較小的值辈赋,并使用它構(gòu)建一個dom
屬性鲫忍,也就是表示組件的 DOM。 大多數(shù)構(gòu)造器還會接受一些其他值钥屈,這些值不會隨著時間而改變悟民,例如它們可用于分派操作的函數(shù)。
每個組件都有一個setState
方法篷就,用于將其同步到新的狀態(tài)值射亏。 該方法接受一個參數(shù),該參數(shù)的類型與構(gòu)造器的第一個參數(shù)的類型相同。
狀態(tài)
應(yīng)用狀態(tài)將是一個帶有圖片智润,工具和顏色屬性的對象及舍。 圖片本身就是一個對象,存儲圖片的寬度窟绷,高度和像素內(nèi)容锯玛。 像素逐行存儲在一個數(shù)組中,方式與第 6 章中的矩陣類相同兼蜈,按行存儲攘残,從上到下。
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);
}
}
我們希望能夠?qū)D片當(dāng)做不變的值为狸,我們將在本章后面回顧其原因歼郭。 但是我們有時也需要一次更新大量像素。 為此辐棒,該類有draw
方法病曾,接受更新后的像素(具有x
,y
和color
屬性的對象)的數(shù)組漾根,并創(chuàng)建一個覆蓋這些像素的新圖像知态。 此方法使用不帶參數(shù)的slice
來復(fù)制整個像素數(shù)組 - 切片的起始位置默認(rèn)為 0,結(jié)束位置為數(shù)組的長度立叛。
empty
方法使用我們以前沒有見過的兩個數(shù)組功能负敏。 可以使用數(shù)字調(diào)用Array
構(gòu)造器來創(chuàng)建給定長度的空數(shù)組。 然后fill
方法可以用于使用給定值填充數(shù)組秘蛇。 這些用于創(chuàng)建一個數(shù)組其做,所有像素具有相同顏色。
顏色存儲為字符串赁还,包含傳統(tǒng) CSS 顏色代碼 - 一個井號(#
)妖泄,后跟六個十六進(jìn)制數(shù)字,兩個用于紅色分量艘策,兩個用于綠色分量蹈胡,兩個用于藍(lán)色分量。這是一種有點神秘而不方便的顏色編寫方法朋蔫,但它是 HTML 顏色輸入字段使用的格式罚渐,并且可以在canva
s繪圖上下文的fillColor
屬性中使用,所以對于我們在程序中使用顏色的方式驯妄,它足夠?qū)嵱谩?/p>
所有分量都為零的黑色寫成"#000000"
荷并,亮粉色看起來像#ff00ff"
,其中紅色和藍(lán)色分量的最大值為 255青扔,以十六進(jìn)制數(shù)字寫為ff
(a
到f
用作數(shù)字 10 到 15)源织。
我們將允許界面將動作分派為對象翩伪,它是屬性覆蓋先前狀態(tài)的屬性。當(dāng)用戶改變顏色字段時谈息,顏色字段可以分派像{color: field.value}
這樣的對象缘屹,從這個對象可以計算出一個新的狀態(tài)。
function updateState(state, action) {
return Object.assign({}, state, action);
}
這是相當(dāng)麻煩的模式侠仇,其中Object.assign
用于首先將狀態(tài)屬性添加到空對象囊颅,然后使用來自動作的屬性覆蓋其中的一些屬性,這在使用不可變對象的 JavaScript 代碼中很常見傅瞻。 一個更方便的表示法處于標(biāo)準(zhǔn)化的最后階段踢代,也就是在對象表達(dá)式中使用三點運(yùn)算符來包含另一個對象的所有屬性。 有了這個補(bǔ)充嗅骄,你可以寫出{...state, ...action}
胳挎。 在撰寫本文時,這還不適用于所有瀏覽器溺森。
DOM 的構(gòu)建
界面組件做的主要事情之一是創(chuàng)建 DOM 結(jié)構(gòu)慕爬。 我們再也不想直接使用冗長的 DOM 方法,所以這里是elt
函數(shù)的一個稍微擴(kuò)展的版本屏积。
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;
}
這個版本與我們在第 16 章中使用的版本之間的主要區(qū)別在于医窿,它將屬性(property)分配給 DOM 節(jié)點,而不是屬性(attribute)炊林。 這意味著我們不能用它來設(shè)置任意屬性(attribute)姥卢,但是我們可以用它來設(shè)置值不是字符串的屬性(property),比如onclick
渣聚,可以將它設(shè)置為一個函數(shù)独榴,來注冊點擊事件處理器。
這允許這種注冊事件處理器的方式:
<body>
<script>
document.body.appendChild(elt("button", {
onclick: () => console.log("click")
}, "The button"));
</script>
</body>
畫布
我們要定義的第一個組件是界面的一部分奕枝,它將圖片顯示為彩色框的網(wǎng)格棺榔。 該組件負(fù)責(zé)兩件事:顯示圖片并將該圖片上的指針事件傳給應(yīng)用的其余部分。
因此隘道,我們可以將其定義為僅了解當(dāng)前圖片症歇,而不是整個應(yīng)用狀態(tài)的組件。 因為它不知道整個應(yīng)用是如何工作的谭梗,所以不能直接發(fā)送操作忘晤。 相反,當(dāng)響應(yīng)指針事件時默辨,它會調(diào)用創(chuàng)建它的代碼提供的回調(diào)函數(shù)德频,該函數(shù)將處理應(yīng)用的特定部分苍息。
const scale = 10;
class PictureCanvas {
constructor(picture, pointerDown) {
this.dom = elt("canvas", {
onmousedown: event => this.mouse(event, pointerDown),
ontouchstart: event => this.touch(event, pointerDown)
});
drawPicture(picture, this.dom, scale);
}
setState(picture) {
if (this.picture == picture) return;
this.picture = picture;
drawPicture(this.picture, this.dom, scale);
}
}
我們將每個像素繪制成一個10x10
的正方形缩幸,由比例常數(shù)決定壹置。 為了避免不必要的工作,該組件會跟蹤其當(dāng)前圖片表谊,并且僅當(dāng)將setState
賦予新圖片時才會重繪钞护。
實際的繪圖功能根據(jù)比例和圖片大小設(shè)置畫布大小,并用一系列正方形填充它爆办,每個像素一個难咕。
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);
}
}
}
當(dāng)鼠標(biāo)懸停在圖片畫布上,并且按下鼠標(biāo)左鍵時距辆,組件調(diào)用pointerDown
回調(diào)函數(shù)余佃,提供被點擊圖片坐標(biāo)的像素位置。 這將用于實現(xiàn)鼠標(biāo)與圖片的交互跨算。 回調(diào)函數(shù)可能會返回另一個回調(diào)函數(shù)爆土,以便在按下按鈕并且將指針移動到另一個像素時得到通知。
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)};
}
由于我們知道像素的大小诸蚕,我們可以使用getBoundingClientRect
來查找畫布在屏幕上的位置步势,所以可以將鼠標(biāo)事件坐標(biāo)(clientX
和clientY
)轉(zhuǎn)換為圖片坐標(biāo)。 它們總是向下取舍背犯,以便它們指代特定的像素坏瘩。
對于觸摸事件,我們必須做類似的事情漠魏,但使用不同的事件倔矾,并確保我們在"touchstart"
事件中調(diào)用preventDefault
以防止滑動。
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);
};
對于觸摸事件柱锹,clientX
和clientY
不能直接在事件對象上使用破讨,但我們可以在touches
屬性中使用第一個觸摸對象的坐標(biāo)。
應(yīng)用
為了能夠逐步構(gòu)建應(yīng)用奕纫,我們將主要組件實現(xiàn)為畫布周圍的外殼提陶,以及一組動態(tài)工具和控件,我們將其傳遞給其構(gòu)造器匹层。
控件是出現(xiàn)在圖片下方的界面元素隙笆。 它們?yōu)榻M件構(gòu)造器的數(shù)組而提供。
工具是繪制像素或填充區(qū)域的東西升筏。 該應(yīng)用將一組可用工具顯示為<select>
字段撑柔。 當(dāng)前選擇的工具決定了,當(dāng)用戶使用指針設(shè)備與圖片交互時您访,發(fā)生的事情铅忿。 它們作為一個對象而提供,該對象將出現(xiàn)在下拉字段中的名稱灵汪,映射到實現(xiàn)這些工具的函數(shù)檀训。 這個函數(shù)接受圖片位置柑潦,當(dāng)前應(yīng)用狀態(tài)和dispatch
函數(shù)作為參數(shù)。 它們可能會返回一個移動處理器峻凫,當(dāng)指針移動到另一個像素時渗鬼,使用新位置和當(dāng)前狀態(tài)調(diào)用該函數(shù)。
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), []));
}
setState(state) {
this.state = state;
this.canvas.setState(state.picture);
for (let ctrl of this.controls) ctrl.setState(state);
}
}
指定給PictureCanvas
的指針處理器荧琼,使用適當(dāng)?shù)膮?shù)調(diào)用當(dāng)前選定的工具譬胎,如果返回了移動處理器,使其也接收狀態(tài)命锄。
所有控件在this.controls
中構(gòu)造并存儲堰乔,以便在應(yīng)用狀態(tài)更改時更新它們。 reduce
的調(diào)用會在控件的 DOM 元素之間引入空格脐恩。 這樣他們看起來并不那么密集浩考。
第一個控件是工具選擇菜單。 它創(chuàng)建<select>
元素被盈,每個工具帶有一個選項析孽,并設(shè)置"change"
事件處理器,用于在用戶選擇不同的工具時更新應(yīng)用狀態(tài)只怎。
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);
}
setState(state) { this.select.value = state.tool; }
}
通過將標(biāo)簽文本和字段包裝在<label>
元素中袜瞬,我們告訴瀏覽器該標(biāo)簽屬于該字段,例如身堡,你可以點擊標(biāo)簽來聚焦該字段邓尤。
我們還需要能夠改變顏色 - 所以讓我們添加一個控件。 type
屬性為顏色的 HTML <input>
元素為我們提供了專門用于選擇顏色的表單字段贴谎。 這種字段的值始終是"#RRGGBB"
格式(紅色汞扎,綠色和藍(lán)色分量,每種顏色兩位數(shù)字)的 CSS 顏色代碼擅这。 當(dāng)用戶與它交互時郭卫,瀏覽器將顯示一個顏色選擇器界面乃摹。
該控件創(chuàng)建這樣一個字段,并將其連接起來,與應(yīng)用狀態(tài)的color
屬性保持同步许饿。
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);
}
setState(state) { this.input.value = state.color; }
}
繪圖工具
在我們繪制任何東西之前愕贡,我們需要實現(xiàn)一些工具济舆,來控制畫布上的鼠標(biāo)或觸摸事件的功能抵怎。
最基本的工具是繪圖工具,它可以將你點擊或輕觸的任何像素玫坛,更改為當(dāng)前選定的顏色结笨。 它分派一個動作,將圖片更新為一個版本,其中所指的像素賦為當(dāng)前選定的顏色炕吸。
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;
}
該函數(shù)立即調(diào)用drawPixel
函數(shù)伐憾,但也會返回它,以便在用戶在圖片上拖動或滑動時算途,再次為新的所觸摸的像素調(diào)用塞耕。
為了繪制較大的形狀蚀腿,可以快速創(chuàng)建矩形嘴瓤。 矩形工具在開始拖動的點和拖動到的點之間畫一個矩形。
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;
}
此實現(xiàn)中的一個重要細(xì)節(jié)是莉钙,拖動時廓脆,矩形將從原始狀態(tài)重新繪制在圖片上。 這樣磁玉,你可以在創(chuàng)建矩形時將矩形再次放大和縮小停忿,中間的矩形不會在最終圖片中殘留。 這是不可變圖片對象實用的原因之一 - 稍后我們會看到另一個原因蚊伞。
實現(xiàn)洪水填充涉及更多東西席赂。 這是一個工具,填充和指針下的像素时迫,和顏色相同的所有相鄰像素颅停。 “相鄰”是指水平或垂直直接相鄰,而不是對角線掠拳。 此圖片表明癞揉,在標(biāo)記像素處使用填充工具時,著色的一組像素:
https://raw.githubusercontent.com/wizardforcel/eloquent-js-3e-zh/master/img/19-2.svg
有趣的是溺欧,我們的實現(xiàn)方式看起來有點像第 7 章中的尋路代碼喊熟。那個代碼搜索圖來查找路線,但這個代碼搜索網(wǎng)格來查找所有“連通”的像素姐刁。 跟蹤一組可能的路線的問題是類似的芥牌。
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}];
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 &&
state.picture.pixel(x, y) == targetColor &&
!drawn.some(p => p.x == x && p.y == y)) {
drawn.push({x, y, color: state.color});
}
}
}
dispatch({picture: state.picture.draw(drawn)});
}
繪制完成的像素的數(shù)組可以兼作函數(shù)的工作列表。 對于每個到達(dá)的像素聂使,我們必須看看任何相鄰的像素是否顏色相同胳泉,并且尚未覆蓋。 隨著新像素的添加岩遗,循環(huán)計數(shù)器落后于繪制完成的數(shù)組的長度扇商。 任何前面的像素仍然需要探索。 當(dāng)它趕上長度時宿礁,沒有剩下未探測的像素案铺,并且該函數(shù)就完成了。
最終的工具是一個顏色選擇器梆靖,它允許你指定圖片中的顏色控汉,來將其用作當(dāng)前的繪圖顏色笔诵。
function pick(pos, state, dispatch) {
dispatch({color: state.picture.pixel(pos.x, pos.y)});
}
我們現(xiàn)在可以測試我們的應(yīng)用了!
<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.setState(state);
}
});
document.querySelector("div").appendChild(app.dom);
</script>
保存和加載
當(dāng)我們畫出我們的杰作時姑子,我們會想要保存它以備后用乎婿。 我們應(yīng)該添加一個按鈕,用于將當(dāng)前圖片下載為圖片文件街佑。 這個控件提供了這個按鈕:
class SaveButton {
constructor(state) {
this.picture = state.picture;
this.dom = elt("button", {
onclick: () => this.save()
}, "\u{1f4be} 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();
}
setState(state) { this.picture = state.picture; }
}
組件會跟蹤當(dāng)前圖片谢翎,以便在保存時可以訪問它。 為了創(chuàng)建圖像文件沐旨,它使用<canvas>
元素來繪制圖片(一比一的像素比例)森逮。
canvas
元素上的toDataURL
方法創(chuàng)建一個以data:
開頭的 URL。 與http:
和https:
的 URL 不同磁携,數(shù)據(jù) URL 在 URL 中包含整個資源褒侧。 它們通常很長,但它們允許我們在瀏覽器中谊迄,創(chuàng)建任意圖片的可用鏈接闷供。
為了讓瀏覽器真正下載圖片,我們將創(chuàng)建一個鏈接元素统诺,指向此 URL 并具有download
屬性歪脏。 點擊這些鏈接后,瀏覽器將顯示一個文件保存對話框篙议。 我們將該鏈接添加到文檔唾糯,模擬點擊它,然后再將其刪除鬼贱。
你可以使用瀏覽器技術(shù)做很多事情移怯,但有時候做這件事的方式很奇怪。
并且情況變得更糟了这难。 我們也希望能夠?qū)F(xiàn)有的圖像文件加載到我們的應(yīng)用中舟误。 為此,我們再次定義一個按鈕組件姻乓。
class LoadButton {
constructor(_, {dispatch}) {
this.dom = elt("button", {
onclick: () => startLoad(dispatch)
}, "\u{1f4c1} Load");
}
setState() {}
}
function startLoad(dispatch) {
let input = elt("input", {
type: "file",
onchange: () => finishLoad(input.files[0], dispatch)
});
document.body.appendChild(input);
input.click();
input.remove();
}
為了訪問用戶計算機(jī)上的文件嵌溢,我們需要用戶通過文件輸入字段選擇文件。 但我不希望加載按鈕看起來像文件輸入字段蹋岩,所以我們在單擊按鈕時創(chuàng)建文件輸入赖草,然后假裝它自己被單擊。
當(dāng)用戶選擇一個文件時剪个,我們可以使用FileReader
訪問其內(nèi)容秧骑,并再次作為數(shù)據(jù) URL。 該 URL 可用于創(chuàng)建<img>
元素,但由于我們無法直接訪問此類圖像中的像素乎折,因此我們無法從中創(chuàng)建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);
}
為了訪問像素,我們必須先將圖片繪制到<canvas>
元素骂澄。 canvas
上下文有一個getImageData
方法吓蘑,允許腳本讀取其像素。 所以一旦圖片在畫布上坟冲,我們就可以訪問它并構(gòu)建一個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
像素,因為任何更大的圖像在我們的顯示器上看起來都很大樱衷,并且可能會拖慢界面棋嘲。
getImageData
返回的對象的data
屬性酒唉,是一個顏色分量的數(shù)組矩桂。 對于由參數(shù)指定的矩形中的每個像素,它包含四個值痪伦,分別表示像素顏色的紅色侄榴,綠色,藍(lán)色和 alpha 分量网沾,數(shù)字介于 0 和 255 之間癞蚕。alpha 分量表示不透明度 - 當(dāng)它是零時像素是完全透明的,當(dāng)它是 255 時辉哥,它是完全不透明的桦山。出于我們的目的,我們可以忽略它醋旦。
在我們的顏色符號中恒水,為每個分量使用的兩個十六進(jìn)制數(shù)字,正好對應(yīng)于 0 到 255 的范圍 - 兩個十六進(jìn)制數(shù)字可以表示16**2 = 256
個不同的數(shù)字饲齐。 數(shù)字的toString
方法可以傳入進(jìn)制作為參數(shù)钉凌,所以n.toString(16)
將產(chǎn)生十六進(jìn)制的字符串表示。我們必須確保每個數(shù)字都占用兩位數(shù)捂人,所以十六進(jìn)制的輔助函數(shù)調(diào)用padStart
御雕,在必要時添加前導(dǎo)零。
我們現(xiàn)在可以加載并保存了滥搭! 在完成之前剩下一個功能酸纲。
撤銷歷史
編輯過程的一半是犯了小錯誤,并再次糾正它們瑟匆。 因此闽坡,繪圖程序中的一個非常重要的功能是撤消歷史。
為了能夠撤銷更改,我們需要存儲以前版本的圖片无午。 由于這是一個不可變的值媒役,這很容易。 但它確實需要應(yīng)用狀態(tài)中的額外字段宪迟。
我們將添加done
數(shù)組來保留圖片的以前版本酣衷。 維護(hù)這個屬性需要更復(fù)雜的狀態(tài)更新函數(shù),它將圖片添加到數(shù)組中次泽。
但我們不希望存儲每一個更改穿仪,而是一定時間量之后的更改。 為此意荤,我們需要第二個屬性doneAt
啊片,跟蹤我們上次在歷史中存儲圖片的時間。
function historyUpdateState(state, action) {
if (action.undo == true) {
if (state.done.length == 0) return state;
return Object.assign({}, state, {
picture: state.done[0],
done: state.done.slice(1),
doneAt: 0
});
} else if (action.picture &&
state.doneAt < Date.now() - 1000) {
return Object.assign({}, state, action, {
done: [state.picture, ...state.done],
doneAt: Date.now()
});
} else {
return Object.assign({}, state, action);
}
}
當(dāng)動作是撤消動作時玖像,該函數(shù)將從歷史中獲取最近的圖片紫谷,并生成當(dāng)前圖片。
或者捐寥,如果動作包含新圖片笤昨,并且上次存儲東西的時間超過了一秒(1000 毫秒),會更新done
和doneAt
屬性來存儲上一張圖片握恳。
撤消按鈕組件不會做太多事情瞒窒。 它在點擊時分派撤消操作,并在沒有任何可以撤銷的東西時禁用自身乡洼。
class UndoButton {
constructor(state, {dispatch}) {
this.dom = elt("button", {
onclick: () => dispatch({undo: true}),
disabled: state.done.length == 0
}, "? Undo");
}
setState(state) {
this.dom.disabled = state.done.length == 0;
}
}
讓我們繪圖吧
為了建立應(yīng)用崇裁,我們需要創(chuàng)建一個狀態(tài),一組工具束昵,一組控件和一個分派函數(shù)拔稳。 我們可以將它們傳遞給PixelEditor
構(gòu)造器來創(chuàng)建主要組件。 由于我們需要在練習(xí)中創(chuàng)建多個編輯器妻怎,因此我們首先定義一些綁定壳炎。
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.setState(state);
}
});
return app.dom;
}
解構(gòu)對象或數(shù)組時,可以在綁定名稱后面使用=
逼侦,來為綁定指定默認(rèn)值匿辩,該屬性在缺失或未定義時使用。 startPixelEditor
函數(shù)利用它來接受一個對象榛丢,包含許多可選屬性作為參數(shù)铲球。 例如,如果你未提供tools
屬性晰赞,則tools
將綁定到baseTools
稼病。
這就是我們在屏幕上獲得實際的編輯器的方式:
<div></div>
<script>
document.querySelector("div")
.appendChild(startPixelEditor({}));
</script>
來吧选侨,畫一些東西。 我會等著你然走。
為什么這個很困難
瀏覽器技術(shù)是驚人的援制。 它提供了一組強(qiáng)大的界面積木,排版和操作方法芍瑞,以及檢查和調(diào)試應(yīng)用的工具晨仑。 你為瀏覽器編寫的軟件可以在幾乎所有電腦和手機(jī)上運(yùn)行。
與此同時拆檬,瀏覽器技術(shù)是荒謬的洪己。 你必須學(xué)習(xí)大量愚蠢的技巧和難懂的事實才能掌握它,而它提供的默認(rèn)編程模型非常棘手竟贯,大多數(shù)程序員喜歡用幾層抽象來封裝它答捕,而不是直接處理它。
雖然情況肯定有所改善屑那,但它以增加更多元素來解決缺點的方式拱镐,改善了它 - 也創(chuàng)造了更多復(fù)雜性。 數(shù)百萬個網(wǎng)站使用的特性無法真正被取代齐莲。 即使可能痢站,也很難決定它應(yīng)該由什么取代磷箕。
技術(shù)從不存在于真空中 - 我們受到我們的工具选酗,以及產(chǎn)生它們的社會,經(jīng)濟(jì)和歷史因素的制約岳枷。 這可能很煩人芒填,但通常更加有效的是,試圖理解現(xiàn)有的技術(shù)現(xiàn)實如何發(fā)揮作用空繁,以及為什么它是這樣 - 而不是對抗它殿衰,或者轉(zhuǎn)向另一個現(xiàn)實。
新的抽象可能會有所幫助盛泡。 我在本章中使用的組件模型和數(shù)據(jù)流約定闷祥,是一種粗糙的抽象。 如前所述傲诵,有些庫試圖使用戶界面編程更愉快凯砍。 在編寫本文時,React 和 Angular 是主流選擇拴竹,但是這樣的框架帶有整個全家桶悟衩。 如果你對編寫 Web 應(yīng)用感興趣,我建議調(diào)查其中的一些內(nèi)容栓拜,來了解它們的原理座泳,以及它們提供的好處惠昔。
練習(xí)
我們的程序還有提升空間。讓我們添加一些更多特性作為練習(xí)挑势。
鍵盤綁定
將鍵盤快捷鍵添加到應(yīng)用镇防。 工具名稱的第一個字母用于選擇工具,而control-Z
或command-Z
激活撤消工作潮饱。
通過修改PixelEditor
組件來實現(xiàn)它营罢。 為<div>
元素包裝添加tabIndex
屬性 0,以便它可以接收鍵盤焦點饼齿。 請注意饲漾,與tabindex
屬性對應(yīng)的屬性稱為tabIndex
,I
大寫缕溉,我們的elt
函數(shù)需要屬性名稱考传。 直接在該元素上注冊鍵盤事件處理器。 這意味著你必須先單擊证鸥,觸摸或按下 TAB 選擇應(yīng)用僚楞,然后才能使用鍵盤與其交互。
請記住枉层,鍵盤事件具有ctrlKey
和metaKey
(用于 Mac 上的Command
鍵)屬性泉褐,你可以使用它們查看這些鍵是否被按下。
<div></div>
<script>
// The original PixelEditor class. Extend the constructor.
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), []));
}
setState(state) {
this.state = state;
this.canvas.setState(state.picture);
for (let ctrl of this.controls) ctrl.setState(state);
}
}
document.querySelector("div")
.appendChild(startPixelEditor({}));
</script>
高效繪圖
繪圖過程中鸟蜡,我們的應(yīng)用所做的大部分工作都發(fā)生在drawPicture
中膜赃。 創(chuàng)建一個新狀態(tài)并更新 DOM 的其余部分的開銷并不是很大,但重新繪制畫布上的所有像素是相當(dāng)大的工作量揉忘。
找到一種方法跳座,通過重新繪制實際更改的像素,使PictureCanvas
的setState
方法更快泣矛。
請記住疲眷,drawPicture
也由保存按鈕使用,所以如果你更改它您朽,請確保更改不會破壞舊用途狂丝,或者使用不同名稱創(chuàng)建新版本。
另請注意哗总,通過設(shè)置其width
或height
屬性來更改<canvas>
元素的大小几颜,將清除它,使其再次完全透明魂奥。
<div></div>
<script>
// Change this method
PictureCanvas.prototype.setState = function(picture) {
if (this.picture == picture) return;
this.picture = picture;
drawPicture(this.picture, this.dom, scale);
};
// You may want to use or change this as well
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);
}
}
}
document.querySelector("div")
.appendChild(startPixelEditor({}));
</script>
圓
定義一個名為circle
的工具菠剩,當(dāng)你拖動時繪制一個實心圓。 圓的中心位于拖動或觸摸手勢開始的位置耻煤,其半徑由拖動的距離決定具壮。
<div></div>
<script>
function circle(pos, state, dispatch) {
// Your code here
}
let dom = startPixelEditor({
tools: Object.assign({}, baseTools, {circle})
});
document.querySelector("div").appendChild(dom);
</script>
合適的直線
這是比前兩個更高級的練習(xí)准颓,它將要求你設(shè)計一個有意義的問題的解決方案。 在開始這個練習(xí)之前棺妓,確保你有充足的時間和耐心攘已,并且不要因最初的失敗而感到氣餒。
在大多數(shù)瀏覽器上怜跑,當(dāng)你選擇繪圖工具并快速在圖片上拖動時样勃,你不會得到一條閉合直線。 相反性芬,由于"mousemove"
或"touchmove"
事件沒有快到足以命中每個像素峡眶,因此你會得到一些點,在它們之間有空隙植锉。
改進(jìn)繪制工具辫樱,使其繪制完整的直線。 這意味著你必須使移動處理器記住前一個位置俊庇,并將其連接到當(dāng)前位置狮暑。
為此,由于像素可以是任意距離辉饱,所以你必須編寫一個通用的直線繪制函數(shù)搬男。
兩個像素之間的直線是連接像素的鏈條,從起點到終點盡可能直彭沼。對角線相鄰的像素也算作連接缔逛。 所以斜線應(yīng)該看起來像左邊的圖片,而不是右邊的圖片溜腐。
https://raw.githubusercontent.com/wizardforcel/eloquent-js-3e-zh/master/img/19-3.svg
如果我們有了代碼译株,它在兩個任意點間繪制一條直線,我們不妨繼續(xù)挺益,并使用它來定義line
工具,它在拖動的起點和終點之間繪制一條直線乘寒。
<div></div>
<script>
// The old draw tool. Rewrite this.
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) {
// Your code here
}
let dom = startPixelEditor({
tools: {draw, line, fill, rectangle, pick}
});
document.querySelector("div").appendChild(dom);
</script>