<ruby id="bdb3f"></ruby>

    <p id="bdb3f"><cite id="bdb3f"></cite></p>

      <p id="bdb3f"><cite id="bdb3f"><th id="bdb3f"></th></cite></p><p id="bdb3f"></p>
        <p id="bdb3f"><cite id="bdb3f"></cite></p>

          <pre id="bdb3f"></pre>
          <pre id="bdb3f"><del id="bdb3f"><thead id="bdb3f"></thead></del></pre>

          <ruby id="bdb3f"><mark id="bdb3f"></mark></ruby><ruby id="bdb3f"></ruby>
          <pre id="bdb3f"><pre id="bdb3f"><mark id="bdb3f"></mark></pre></pre><output id="bdb3f"></output><p id="bdb3f"></p><p id="bdb3f"></p>

          <pre id="bdb3f"><del id="bdb3f"><progress id="bdb3f"></progress></del></pre>

                <ruby id="bdb3f"></ruby>

                ThinkChat2.0新版上線,更智能更精彩,支持會話、畫圖、視頻、閱讀、搜索等,送10W Token,即刻開啟你的AI之旅 廣告
                # 十九、項目:像素藝術編輯器 > 原文:[Project: A Pixel Art Editor](http://eloquentjavascript.net/19_paint.html) > > 譯者:[飛龍](https://github.com/wizardforcel) > > 協議:[CC BY-NC-SA 4.0](http://creativecommons.org/licenses/by-nc-sa/4.0/) > > 自豪地采用[谷歌翻譯](https://translate.google.cn/) > 我看著眼前的許多顏色。 我看著我的空白畫布。 然后,我嘗試使用顏色,就像形成詩歌的詞語,就像塑造音樂的音符。 > > Joan Miro ![](https://img.kancloud.cn/b9/06/b9063d7d13eb5bd0b7abeb8fddf8ca84_490x310.jpg) 前面幾章的內容為你提供了構建基本的 Web 應用所需的所有元素。 在本章中,我們將實現一個。 我們的應用將是像素繪圖程序,你可以通過操縱放大視圖(正方形彩色網格),來逐像素修改圖像。 你可以使用它來打開圖像文件,用鼠標或其他指針設備在它們上面涂畫并保存。 這是它的樣子: ![](https://img.kancloud.cn/c0/ab/c0ab76fbff3ff070afa8f08801cbfd21_720x341.png) 在電腦上繪畫很棒。 你不需要擔心材料,技能或天賦。 你只需要開始涂畫。 ## 組件 應用的界面在頂部顯示大的`<canvas>`元素,在它下面有許多表單字段。 用戶通過從`<select>`字段中選擇工具,然后單擊,觸摸或拖動畫布來繪制圖片。 有用于繪制單個像素或矩形,填充區域以及從圖片中選取顏色的工具。 我們將編輯器界面構建為多個組件和對象,負責 DOM 的一部分,并可能在其中包含其他組件。 應用的狀態由當前圖片,所選工具和所選顏色組成。 我們將建立一些東西,以便狀態存在于單一的值中,并且界面組件總是基于當前狀態下他們看上去的樣子。 為了明白為什么這很重要,讓我們考慮替代方案:將狀態片段分配給整個界面。 直到某個時期,這更容易編寫。 我們可以放入顏色字段,并在需要知道當前顏色時讀取其值。 但是,我們添加了顏色選擇器。它是一種工具,可讓你單擊圖片來選擇給定像素的顏色。 為了保持顏色字段顯示正確的顏色,該工具必須知道它存在,并在每次選擇新顏色時對其進行更新。 如果你添加了另一個讓顏色可見的地方(也許鼠標光標可以顯示它),你必須更新你的改變顏色的代碼來保持同步。 實際上,這會讓你遇到一個問題,即界面的每個部分都需要知道所有其他部分,它們并不是非常模塊化的。 對于本章中的小應用,這可能不成問題。 對于更大的項目,它可能變成真正的噩夢。 所以為了在原則上避免這種噩夢,我們將對數據流非常嚴格。 存在一個狀態,界面根據該狀態繪制。 界面組件可以通過更新狀態來響應用戶動作,此時組件有機會與新的狀態進行同步。 在實踐中,每個組件的建立,都是為了在給定一個新的狀態時,它還會通知它的子組件,只要這些組件需要更新。 建立這個有點麻煩。 讓這個更方便是許多瀏覽器編程庫的主要賣點。 但對于像這樣的小應用,我們可以在沒有這種基礎設施的情況下完成。 狀態更新表示為對象,我們將其稱為動作。 組件可以創建這樣的動作并分派它們 - 將它們給予中央狀態管理函數。 該函數計算下一個狀態,之后界面組件將自己更新為這個新狀態。 我們正在執行一個混亂的任務,運行一個用戶界面并對其應用一些結構。 盡管與 DOM 相關的部分仍然充滿了副作用,但它們由一個概念上簡單的主干支撐 - 狀態更新循環。 狀態決定了 DOM 的外觀,而 DOM 事件可以改變狀態的唯一方法,是向狀態分派動作。 這種方法有許多變種,每個變種都有自己的好處和問題,但它們的中心思想是一樣的:狀態變化應該通過明確定義的渠道,而不是遍布整個地方。 我們的組件將是與界面一致的類。 他們的構造器被賦予一個狀態,它可能是整個應用狀態,或者如果它不需要訪問所有東西,是一些較小的值,并使用它構建一個`dom`屬性,也就是表示組件的 DOM。 大多數構造器還會接受一些其他值,這些值不會隨著時間而改變,例如它們可用于分派操作的函數。 每個組件都有一個`setState`方法,用于將其同步到新的狀態值。 該方法接受一個參數,該參數的類型與構造器的第一個參數的類型相同。 ## 狀態 應用狀態將是一個帶有圖片,工具和顏色屬性的對象。 圖片本身就是一個對象,存儲圖片的寬度,高度和像素內容。 像素逐行存儲在一個數組中,方式與第 6 章中的矩陣類相同,按行存儲,從上到下。 ```js 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}`這樣的對象,從這個對象可以計算出一個新的狀態。 ```js function updateState(state, action) { return Object.assign({}, state, action); } ``` 這是相當麻煩的模式,其中`Object.assign`用于首先將狀態屬性添加到空對象,然后使用來自動作的屬性覆蓋其中的一些屬性,這在使用不可變對象的 JavaScript 代碼中很常見。 一個更方便的表示法處于標準化的最后階段,也就是在對象表達式中使用三點運算符來包含另一個對象的所有屬性。 有了這個補充,你可以寫出`{...state, ...action}`。 在撰寫本文時,這還不適用于所有瀏覽器。 ## DOM 的構建 界面組件做的主要事情之一是創建 DOM 結構。 我們再也不想直接使用冗長的 DOM 方法,所以這里是`elt`函數的一個稍微擴展的版本。 ```js 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`,可以將它設置為一個函數,來注冊點擊事件處理器。 這允許這種注冊事件處理器的方式: ```js <body> <script> document.body.appendChild(elt("button", { onclick: () => console.log("click") }, "The button")); </script> </body> ``` ## 畫布 我們要定義的第一個組件是界面的一部分,它將圖片顯示為彩色框的網格。 該組件負責兩件事:顯示圖片并將該圖片上的指針事件傳給應用的其余部分。 因此,我們可以將其定義為僅了解當前圖片,而不是整個應用狀態的組件。 因為它不知道整個應用是如何工作的,所以不能直接發送操作。 相反,當響應指針事件時,它會調用創建它的代碼提供的回調函數,該函數將處理應用的特定部分。 ```js 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`賦予新圖片時才會重繪。 實際的繪圖功能根據比例和圖片大小設置畫布大小,并用一系列正方形填充它,每個像素一個。 ```js 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`回調函數,提供被點擊圖片坐標的像素位置。 這將用于實現鼠標與圖片的交互。 回調函數可能會返回另一個回調函數,以便在按下按鈕并且將指針移動到另一個像素時得到通知。 ```js 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`以防止滑動。 ```js 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`函數作為參數。 它們可能會返回一個移動處理器,當指針移動到另一個像素時,使用新位置和當前狀態調用該函數。 ```js 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"`事件處理器,用于在用戶選擇不同的工具時更新應用狀態。 ```js 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`屬性保持同步。 ```js 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; } } ``` ## 繪圖工具 在我們繪制任何東西之前,我們需要實現一些工具,來控制畫布上的鼠標或觸摸事件的功能。 最基本的工具是繪圖工具,它可以將你點擊或輕觸的任何像素,更改為當前選定的顏色。 它分派一個動作,將圖片更新為一個版本,其中所指的像素賦為當前選定的顏色。 ```js 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`函數,但也會返回它,以便在用戶在圖片上拖動或滑動時,再次為新的所觸摸的像素調用。 為了繪制較大的形狀,可以快速創建矩形。 矩形工具在開始拖動的點和拖動到的點之間畫一個矩形。 ```js 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; } ``` 此實現中的一個重要細節是,拖動時,矩形將從原始狀態重新繪制在圖片上。 這樣,你可以在創建矩形時將矩形再次放大和縮小,中間的矩形不會在最終圖片中殘留。 這是不可變圖片對象實用的原因之一 - 稍后我們會看到另一個原因。 實現洪水填充涉及更多東西。 這是一個工具,填充和指針下的像素,和顏色相同的所有相鄰像素。 “相鄰”是指水平或垂直直接相鄰,而不是對角線。 此圖片表明,在標記像素處使用填充工具時,著色的一組像素: ![](https://box.kancloud.cn/2015-10-31_563439aa84744.svg) 有趣的是,我們的實現方式看起來有點像第 7 章中的尋路代碼。那個代碼搜索圖來查找路線,但這個代碼搜索網格來查找所有“連通”的像素。 跟蹤一組可能的路線的問題是類似的。 ```js 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)}); } ``` 繪制完成的像素的數組可以兼作函數的工作列表。 對于每個到達的像素,我們必須看看任何相鄰的像素是否顏色相同,并且尚未覆蓋。 隨著新像素的添加,循環計數器落后于繪制完成的數組的長度。 任何前面的像素仍然需要探索。 當它趕上長度時,沒有剩下未探測的像素,并且該函數就完成了。 最終的工具是一個顏色選擇器,它允許你指定圖片中的顏色,來將其用作當前的繪圖顏色。 ```js function pick(pos, state, dispatch) { dispatch({color: state.picture.pixel(pos.x, pos.y)}); } ``` 我們現在可以測試我們的應用了! ```html <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> ``` ## 保存和加載 當我們畫出我們的杰作時,我們會想要保存它以備后用。 我們應該添加一個按鈕,用于將當前圖片下載為圖片文件。 這個控件提供了這個按鈕: ```js 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`屬性。 點擊這些鏈接后,瀏覽器將顯示一個文件保存對話框。 我們將該鏈接添加到文檔,模擬點擊它,然后再將其刪除。 你可以使用瀏覽器技術做很多事情,但有時候做這件事的方式很奇怪。 并且情況變得更糟了。 我們也希望能夠將現有的圖像文件加載到我們的應用中。 為此,我們再次定義一個按鈕組件。 ```js 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`對象。 ```js 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`對象。 ```js 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`,跟蹤我們上次在歷史中存儲圖片的時間。 ```js 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`屬性來存儲上一張圖片。 撤消按鈕組件不會做太多事情。 它在點擊時分派撤消操作,并在沒有任何可以撤銷的東西時禁用自身。 ```js 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`構造器來創建主要組件。 由于我們需要在練習中創建多個編輯器,因此我們首先定義一些綁定。 ```js 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`。 這就是我們在屏幕上獲得實際的編輯器的方式: ```js <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`鍵)屬性,你可以使用它們查看這些鍵是否被按下。 ```html <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>`元素的大小,將清除它,使其再次完全透明。 ```html <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`的工具,當你拖動時繪制一個實心圓。 圓的中心位于拖動或觸摸手勢開始的位置,其半徑由拖動的距離決定。 ```html <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"`事件沒有快到足以命中每個像素,因此你會得到一些點,在它們之間有空隙。 改進繪制工具,使其繪制完整的直線。 這意味著你必須使移動處理器記住前一個位置,并將其連接到當前位置。 為此,由于像素可以是任意距離,所以你必須編寫一個通用的直線繪制函數。 兩個像素之間的直線是連接像素的鏈條,從起點到終點盡可能直。對角線相鄰的像素也算作連接。 所以斜線應該看起來像左邊的圖片,而不是右邊的圖片。 ![](https://img.kancloud.cn/fc/3b/fc3b47a08e965585650971d986acf813.svg) 如果我們有了代碼,它在兩個任意點間繪制一條直線,我們不妨繼續,并使用它來定義`line`工具,它在拖動的起點和終點之間繪制一條直線。 ```js <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> ```
                  <ruby id="bdb3f"></ruby>

                  <p id="bdb3f"><cite id="bdb3f"></cite></p>

                    <p id="bdb3f"><cite id="bdb3f"><th id="bdb3f"></th></cite></p><p id="bdb3f"></p>
                      <p id="bdb3f"><cite id="bdb3f"></cite></p>

                        <pre id="bdb3f"></pre>
                        <pre id="bdb3f"><del id="bdb3f"><thead id="bdb3f"></thead></del></pre>

                        <ruby id="bdb3f"><mark id="bdb3f"></mark></ruby><ruby id="bdb3f"></ruby>
                        <pre id="bdb3f"><pre id="bdb3f"><mark id="bdb3f"></mark></pre></pre><output id="bdb3f"></output><p id="bdb3f"></p><p id="bdb3f"></p>

                        <pre id="bdb3f"><del id="bdb3f"><progress id="bdb3f"></progress></del></pre>

                              <ruby id="bdb3f"></ruby>

                              哎呀哎呀视频在线观看