來源: ApacheCN『JavaScript 編程精解 中文第三版』翻譯項目原文:Project: A Pixel Art Editorjavascript
譯者:飛龍html
協議:CC BY-NC-SA 4.0java
自豪地採用谷歌翻譯git
我看着眼前的許多顏色。 我看着個人空白畫布。 而後,我嘗試使用顏色,就像造成詩歌的詞語,就像塑造音樂的音符。程序員
Joan Mirogithub
前面幾章的內容爲你提供了構建基本的 Web 應用所需的全部元素。 在本章中,咱們將實現一個。apache
咱們的應用將是像素繪圖程序,你能夠經過操縱放大視圖(正方形彩色網格),來逐像素修改圖像。 你可使用它來打開圖像文件,用鼠標或其餘指針設備在它們上面塗畫並保存。 這是它的樣子:編程
在電腦上繪畫很棒。 你不須要擔憂材料,技能或天賦。 你只須要開始塗畫。canvas
應用的界面在頂部顯示大的<canvas>
元素,在它下面有許多表單字段。 用戶經過從<select>
字段中選擇工具,而後單擊,觸摸或拖動畫布來繪製圖片。 有用於繪製單個像素或矩形,填充區域以及從圖片中選取顏色的工具。數組
咱們將編輯器界面構建爲多個組件和對象,負責 DOM 的一部分,並可能在其中包含其餘組件。
應用的狀態由當前圖片,所選工具和所選顏色組成。 咱們將創建一些東西,以便狀態存在於單一的值中,而且界面組件老是基於當前狀態下他們看上去的樣子。
爲了明白爲何這很重要,讓咱們考慮替代方案:將狀態片斷分配給整個界面。 直到某個時期,這更容易編寫。 咱們能夠放入顏色字段,並在須要知道當前顏色時讀取其值。
可是,咱們添加了顏色選擇器。它是一種工具,可以讓你單擊圖片來選擇給定像素的顏色。 爲了保持顏色字段顯示正確的顏色,該工具必須知道它存在,並在每次選擇新顏色時對其進行更新。 若是你添加了另外一個讓顏色可見的地方(也許鼠標光標能夠顯示它),你必須更新你的改變顏色的代碼來保持同步。
實際上,這會讓你遇到一個問題,即界面的每一個部分都須要知道全部其餘部分,它們並非很是模塊化的。 對於本章中的小應用,這可能不成問題。 對於更大的項目,它可能變成真正的噩夢。
因此爲了在原則上避免這種噩夢,咱們將對數據流很是嚴格。 存在一個狀態,界面根據該狀態繪製。 界面組件能夠經過更新狀態來響應用戶動做,此時組件有機會與新的狀態進行同步。
在實踐中,每一個組件的創建,都是爲了在給定一個新的狀態時,它還會通知它的子組件,只要這些組件須要更新。 創建這個有點麻煩。 讓這個更方即是許多瀏覽器編程庫的主要賣點。 但對於像這樣的小應用,咱們能夠在沒有這種基礎設施的狀況下完成。
狀態更新表示爲對象,咱們將其稱爲動做。 組件能夠建立這樣的動做並分派它們 - 將它們給予中央狀態管理函數。 該函數計算下一個狀態,以後界面組件將本身更新爲這個新狀態。
咱們正在執行一個混亂的任務,運行一個用戶界面並對其應用一些結構。 儘管與 DOM 相關的部分仍然充滿了反作用,但它們由一個概念上簡單的主幹支撐 - 狀態更新循環。 狀態決定了 DOM 的外觀,而 DOM 事件能夠改變狀態的惟一方法,是向狀態分派動做。
這種方法有許多變種,每一個變種都有本身的好處和問題,但它們的中心思想是同樣的:狀態變化應該經過明肯定義的渠道,而不是遍及整個地方。
咱們的組件將是與界面一致的類。 他們的構造器被賦予一個狀態,它多是整個應用狀態,或者若是它不須要訪問全部東西,是一些較小的值,並使用它構建一個dom
屬性,也就是表示組件的 DOM。 大多數構造器還會接受一些其餘值,這些值不會隨着時間而改變,例如它們可用於分派操做的函數。
每一個組件都有一個setState
方法,用於將其同步到新的狀態值。 該方法接受一個參數,該參數的類型與構造器的第一個參數的類型相同。
應用狀態將是一個帶有圖片,工具和顏色屬性的對象。 圖片自己就是一個對象,存儲圖片的寬度,高度和像素內容。 像素逐行存儲在一個數組中,方式與第 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); } }
咱們但願可以將圖片當作不變的值,咱們將在本章後面回顧其緣由。 可是咱們有時也須要一次更新大量像素。 爲此,該類有draw
方法,接受更新後的像素(具備x
,y
和color
屬性的對象)的數組,並建立一個覆蓋這些像素的新圖像。 此方法使用不帶參數的slice
來複制整個像素數組 - 切片的起始位置默認爲 0,結束位置爲數組的長度。
empty
方法使用咱們之前沒有見過的兩個數組功能。 可使用數字調用Array
構造器來建立給定長度的空數組。 而後fill
方法能夠用於使用給定值填充數組。 這些用於建立一個數組,全部像素具備相同顏色。
顏色存儲爲字符串,包含傳統 CSS 顏色代碼 - 一個井號(#
),後跟六個十六進制數字,兩個用於紅色份量,兩個用於綠色份量,兩個用於藍色份量。這是一種有點神祕而不方便的顏色編寫方法,但它是 HTML 顏色輸入字段使用的格式,而且能夠在canva
s繪圖上下文的fillColor
屬性中使用,因此對於咱們在程序中使用顏色的方式,它足夠實用。
全部份量都爲零的黑色寫成"#000000"
,亮粉色看起來像#ff00ff"
,其中紅色和藍色份量的最大值爲 255,以十六進制數字寫爲ff
(a
到f
用做數字 10 到 15)。
咱們將容許界面將動做分派爲對象,它是屬性覆蓋先前狀態的屬性。當用戶改變顏色字段時,顏色字段能夠分派像{color: field.value}
這樣的對象,從這個對象能夠計算出一個新的狀態。
function updateState(state, action) { return Object.assign({}, state, action); }
這是至關麻煩的模式,其中Object.assign
用於首先將狀態屬性添加到空對象,而後使用來自動做的屬性覆蓋其中的一些屬性,這在使用不可變對象的 JavaScript 代碼中很常見。 一個更方便的表示法處於標準化的最後階段,也就是在對象表達式中使用三點運算符來包含另外一個對象的全部屬性。 有了這個補充,你能夠寫出{...state, ...action}
。 在撰寫本文時,這還不適用於全部瀏覽器。
界面組件作的主要事情之一是建立 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; }
這個版本與咱們在第 16 章中使用的版本之間的主要區別在於,它將屬性(property)分配給 DOM 節點,而不是屬性(attribute)。 這意味着咱們不能用它來設置任意屬性(attribute),可是咱們能夠用它來設置值不是字符串的屬性(property),好比onclick
,能夠將它設置爲一個函數,來註冊點擊事件處理器。
這容許這種註冊事件處理器的方式:
<body> <script> document.body.appendChild(elt("button", { onclick: () => console.log("click") }, "The button")); </script> </body>
咱們要定義的第一個組件是界面的一部分,它將圖片顯示爲彩色框的網格。 該組件負責兩件事:顯示圖片並將該圖片上的指針事件傳給應用的其他部分。
所以,咱們能夠將其定義爲僅瞭解當前圖片,而不是整個應用狀態的組件。 由於它不知道整個應用是如何工做的,因此不能直接發送操做。 相反,當響應指針事件時,它會調用建立它的代碼提供的回調函數,該函數將處理應用的特定部分。
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
的正方形,由比例常數決定。 爲了不沒必要要的工做,該組件會跟蹤其當前圖片,而且僅當將setState
賦予新圖片時纔會重繪。
實際的繪圖功能根據比例和圖片大小設置畫布大小,並用一系列正方形填充它,每一個像素一個。
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); } } }
當鼠標懸停在圖片畫布上,而且按下鼠標左鍵時,組件調用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 { 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
來查找畫布在屏幕上的位置,因此能夠將鼠標事件座標(clientX
和clientY
)轉換爲圖片座標。 它們老是向下取捨,以便它們指代特定的像素。
對於觸摸事件,咱們必須作相似的事情,但使用不一樣的事件,並確保咱們在"touchstart"
事件中調用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
屬性中使用第一個觸摸對象的座標。
爲了可以逐步構建應用,咱們將主要組件實現爲畫布周圍的外殼,以及一組動態工具和控件,咱們將其傳遞給其構造器。
控件是出如今圖片下方的界面元素。 它們爲組件構造器的數組而提供。
工具是繪製像素或填充區域的東西。 該應用將一組可用工具顯示爲<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), [])); } setState(state) { this.state = state; this.canvas.setState(state.picture); for (let ctrl of this.controls) ctrl.setState(state); } }
指定給PictureCanvas
的指針處理器,使用適當的參數調用當前選定的工具,若是返回了移動處理器,使其也接收狀態。
全部控件在this.controls
中構造並存儲,以便在應用狀態更改時更新它們。 reduce
的調用會在控件的 DOM 元素之間引入空格。 這樣他們看起來並不那麼密集。
第一個控件是工具選擇菜單。 它建立<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); } setState(state) { this.select.value = state.tool; } }
經過將標籤文本和字段包裝在<label>
元素中,咱們告訴瀏覽器該標籤屬於該字段,例如,你能夠點擊標籤來聚焦該字段。
咱們還須要可以改變顏色 - 因此讓咱們添加一個控件。 type
屬性爲顏色的 HTML <input>
元素爲咱們提供了專門用於選擇顏色的表單字段。 這種字段的值始終是"#RRGGBB"
格式(紅色,綠色和藍色份量,每種顏色兩位數字)的 CSS 顏色代碼。 當用戶與它交互時,瀏覽器將顯示一個顏色選擇器界面。
該控件建立這樣一個字段,並將其鏈接起來,與應用狀態的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; } }
在咱們繪製任何東西以前,咱們須要實現一些工具,來控制畫布上的鼠標或觸摸事件的功能。
最基本的工具是繪圖工具,它能夠將你點擊或輕觸的任何像素,更改成當前選定的顏色。 它分派一個動做,將圖片更新爲一個版本,其中所指的像素賦爲當前選定的顏色。
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
函數,但也會返回它,以便在用戶在圖片上拖動或滑動時,再次爲新的所觸摸的像素調用。
爲了繪製較大的形狀,能夠快速建立矩形。 矩形工具在開始拖動的點和拖動到的點之間畫一個矩形。
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; }
此實現中的一個重要細節是,拖動時,矩形將從原始狀態從新繪製在圖片上。 這樣,你能夠在建立矩形時將矩形再次放大和縮小,中間的矩形不會在最終圖片中殘留。 這是不可變圖片對象實用的緣由之一 - 稍後咱們會看到另外一個緣由。
實現洪水填充涉及更多東西。 這是一個工具,填充和指針下的像素,和顏色相同的全部相鄰像素。 「相鄰」是指水平或垂直直接相鄰,而不是對角線。 此圖片代表,在標記像素處使用填充工具時,着色的一組像素:
有趣的是,咱們的實現方式看起來有點像第 7 章中的尋路代碼。那個代碼搜索圖來查找路線,但這個代碼搜索網格來查找全部「連通」的像素。 跟蹤一組可能的路線的問題是相似的。
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)}); }
繪製完成的像素的數組能夠兼做函數的工做列表。 對於每一個到達的像素,咱們必須看看任何相鄰的像素是否顏色相同,而且還沒有覆蓋。 隨着新像素的添加,循環計數器落後於繪製完成的數組的長度。 任何前面的像素仍然須要探索。 當它遇上長度時,沒有剩下未探測的像素,而且該函數就完成了。
最終的工具是一個顏色選擇器,它容許你指定圖片中的顏色,來將其用做當前的繪圖顏色。
function pick(pos, state, dispatch) { dispatch({color: state.picture.pixel(pos.x, pos.y)}); }
咱們如今能夠測試咱們的應用了!
<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>
當咱們畫出咱們的傑做時,咱們會想要保存它以備後用。 咱們應該添加一個按鈕,用於將當前圖片下載爲圖片文件。 這個控件提供了這個按鈕:
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; } }
組件會跟蹤當前圖片,以便在保存時能夠訪問它。 爲了建立圖像文件,它使用<canvas>
元素來繪製圖片(一比一的像素比例)。
canvas
元素上的toDataURL
方法建立一個以data:
開頭的 URL。 與http:
和https:
的 URL 不一樣,數據 URL 在 URL 中包含整個資源。 它們一般很長,但它們容許咱們在瀏覽器中,建立任意圖片的可用連接。
爲了讓瀏覽器真正下載圖片,咱們將建立一個連接元素,指向此 URL 並具備download
屬性。 點擊這些連接後,瀏覽器將顯示一個文件保存對話框。 咱們將該連接添加到文檔,模擬點擊它,而後再將其刪除。
你可使用瀏覽器技術作不少事情,但有時候作這件事的方式很奇怪。
而且狀況變得更糟了。 咱們也但願可以將現有的圖像文件加載到咱們的應用中。 爲此,咱們再次定義一個按鈕組件。
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(); }
爲了訪問用戶計算機上的文件,咱們須要用戶經過文件輸入字段選擇文件。 但我不但願加載按鈕看起來像文件輸入字段,因此咱們在單擊按鈕時建立文件輸入,而後僞裝它本身被單擊。
當用戶選擇一個文件時,咱們可使用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); }
爲了訪問像素,咱們必須先將圖片繪製到<canvas>
元素。 canvas
上下文有一個getImageData
方法,容許腳本讀取其像素。 因此一旦圖片在畫布上,咱們就能夠訪問它並構建一個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
屬性,是一個顏色份量的數組。 對於由參數指定的矩形中的每一個像素,它包含四個值,分別表示像素顏色的紅色,綠色,藍色和 alpha 份量,數字介於 0 和 255 之間。alpha 份量表示不透明度 - 當它是零時像素是徹底透明的,當它是 255 時,它是徹底不透明的。出於咱們的目的,咱們能夠忽略它。
在咱們的顏色符號中,爲每一個份量使用的兩個十六進制數字,正好對應於 0 到 255 的範圍 - 兩個十六進制數字能夠表示16**2 = 256
個不一樣的數字。 數字的toString
方法能夠傳入進製做爲參數,因此n.toString(16)
將產生十六進制的字符串表示。咱們必須確保每一個數字都佔用兩位數,因此十六進制的輔助函數調用padStart
,在必要時添加前導零。
咱們如今能夠加載並保存了! 在完成以前剩下一個功能。
編輯過程的一半是犯了小錯誤,並再次糾正它們。 所以,繪圖程序中的一個很是重要的功能是撤消歷史。
爲了可以撤銷更改,咱們須要存儲之前版本的圖片。 因爲這是一個不可變的值,這很容易。 但它確實須要應用狀態中的額外字段。
咱們將添加done
數組來保留圖片的之前版本。 維護這個屬性須要更復雜的狀態更新函數,它將圖片添加到數組中。
但咱們不但願存儲每個更改,而是必定時間量以後的更改。 爲此,咱們須要第二個屬性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); } }
當動做是撤消動做時,該函數將從歷史中獲取最近的圖片,並生成當前圖片。
或者,若是動做包含新圖片,而且上次存儲東西的時間超過了一秒(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; } }
爲了創建應用,咱們須要建立一個狀態,一組工具,一組控件和一個分派函數。 咱們能夠將它們傳遞給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.setState(state); } }); return app.dom; }
解構對象或數組時,能夠在綁定名稱後面使用=
,來爲綁定指定默認值,該屬性在缺失或未定義時使用。 startPixelEditor
函數利用它來接受一個對象,包含許多可選屬性做爲參數。 例如,若是你未提供tools
屬性,則tools
將綁定到baseTools
。
這就是咱們在屏幕上得到實際的編輯器的方式:
<div></div> <script> document.querySelector("div") .appendChild(startPixelEditor({})); </script>
來吧,畫一些東西。 我會等着你。
瀏覽器技術是驚人的。 它提供了一組強大的界面積木,排版和操做方法,以及檢查和調試應用的工具。 你爲瀏覽器編寫的軟件能夠在幾乎全部電腦和手機上運行。
與此同時,瀏覽器技術是荒謬的。 你必須學習大量愚蠢的技巧和難懂的事實才能掌握它,而它提供的默認編程模型很是棘手,大多數程序員喜歡用幾層抽象來封裝它,而不是直接處理它。
雖然狀況確定有所改善,但它以增長更多元素來解決缺點的方式,改善了它 - 也創造了更多複雜性。 數百萬個網站使用的特性沒法真正被取代。 即便可能,也很難決定它應該由什麼取代。
技術從不存在於真空中 - 咱們受到咱們的工具,以及產生它們的社會,經濟和歷史因素的制約。 這可能很煩人,但一般更加有效的是,試圖理解現有的技術現實如何發揮做用,以及爲何它是這樣 - 而不是對抗它,或者轉向另外一個現實。
新的抽象可能會有所幫助。 我在本章中使用的組件模型和數據流約定,是一種粗糙的抽象。 如前所述,有些庫試圖使用戶界面編程更愉快。 在編寫本文時,React 和 Angular 是主流選擇,可是這樣的框架帶有整個全家桶。 若是你對編寫 Web 應用感興趣,我建議調查其中的一些內容,來了解它們的原理,以及它們提供的好處。
咱們的程序還有提高空間。讓咱們添加一些更多特性做爲練習。
將鍵盤快捷鍵添加到應用。 工具名稱的第一個字母用於選擇工具,而control-Z
或command-Z
激活撤消工做。
經過修改PixelEditor
組件來實現它。 爲<div>
元素包裝添加tabIndex
屬性 0,以便它能夠接收鍵盤焦點。 請注意,與tabindex
屬性對應的屬性稱爲tabIndex
,I
大寫,咱們的elt
函數須要屬性名稱。 直接在該元素上註冊鍵盤事件處理器。 這意味着你必須先單擊,觸摸或按下 TAB 選擇應用,而後才能使用鍵盤與其交互。
請記住,鍵盤事件具備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>
繪圖過程當中,咱們的應用所作的大部分工做都發生在drawPicture
中。 建立一個新狀態並更新 DOM 的其他部分的開銷並非很大,但從新繪製畫布上的全部像素是至關大的工做量。
找到一種方法,經過從新繪製實際更改的像素,使PictureCanvas
的setState
方法更快。
請記住,drawPicture
也由保存按鈕使用,因此若是你更改它,請確保更改不會破壞舊用途,或者使用不一樣名稱建立新版本。
另請注意,經過設置其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
的工具,當你拖動時繪製一個實心圓。 圓的中心位於拖動或觸摸手勢開始的位置,其半徑由拖動的距離決定。
<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>
這是比前兩個更高級的練習,它將要求你設計一個有意義的問題的解決方案。 在開始這個練習以前,確保你有充足的時間和耐心,而且不要因最初的失敗而感到氣餒。
在大多數瀏覽器上,當你選擇繪圖工具並快速在圖片上拖動時,你不會獲得一條閉合直線。 相反,因爲"mousemove"
或"touchmove"
事件沒有快到足以命中每一個像素,所以你會獲得一些點,在它們之間有空隙。
改進繪製工具,使其繪製完整的直線。 這意味着你必須使移動處理器記住前一個位置,並將其鏈接到當前位置。
爲此,因爲像素能夠是任意距離,因此你必須編寫一個通用的直線繪製函數。
兩個像素之間的直線是鏈接像素的鏈條,從起點到終點儘量直。對角線相鄰的像素也算做鏈接。 因此斜線應該看起來像左邊的圖片,而不是右邊的圖片。
若是咱們有了代碼,它在兩個任意點間繪製一條直線,咱們不妨繼續,並使用它來定義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>