JavaScript 編程精解 中文第三版 十九、項目:像素藝術(shù)編輯器

十九胞得、項目:像素藝術(shù)編輯器

原文:Project: A Pixel Art Editor

譯者:飛龍

協(xié)議:CC BY-NC-SA 4.0

自豪地采用谷歌翻譯

部分參考了《JavaScript 編程精解(第 2 版)》

我看著眼前的許多顏色痊远。 我看著我的空白畫布。 然后匾效,我嘗試使用顏色,就像形成詩歌的詞語熊锭,就像塑造音樂的音符弧轧。

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方法病曾,接受更新后的像素(具有xycolor屬性的對象)的數(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 顏色輸入字段使用的格式罚渐,并且可以在canvas繪圖上下文的fillColor屬性中使用,所以對于我們在程序中使用顏色的方式驯妄,它足夠?qū)嵱谩?/p>

所有分量都為零的黑色寫成"#000000"荷并,亮粉色看起來像#ff00ff",其中紅色和藍(lán)色分量的最大值為 255青扔,以十六進(jìn)制數(shù)字寫為ffaf用作數(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)(clientXclientY)轉(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);
};

對于觸摸事件柱锹,clientXclientY不能直接在事件對象上使用破讨,但我們可以在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 毫秒),會更新donedoneAt屬性來存儲上一張圖片握恳。

撤消按鈕組件不會做太多事情瞒窒。 它在點擊時分派撤消操作,并在沒有任何可以撤銷的東西時禁用自身乡洼。

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-Zcommand-Z激活撤消工作潮饱。

通過修改PixelEditor組件來實現(xiàn)它营罢。 為<div>元素包裝添加tabIndex屬性 0,以便它可以接收鍵盤焦點饼齿。 請注意饲漾,與tabindex屬性對應(yīng)的屬性稱為tabIndexI大寫缕溉,我們的elt函數(shù)需要屬性名稱考传。 直接在該元素上注冊鍵盤事件處理器。 這意味著你必須先單擊证鸥,觸摸或按下 TAB 選擇應(yīng)用僚楞,然后才能使用鍵盤與其交互。

請記住枉层,鍵盤事件具有ctrlKeymetaKey(用于 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)大的工作量揉忘。

找到一種方法跳座,通過重新繪制實際更改的像素,使PictureCanvassetState方法更快泣矛。

請記住疲眷,drawPicture也由保存按鈕使用,所以如果你更改它您朽,請確保更改不會破壞舊用途狂丝,或者使用不同名稱創(chuàng)建新版本。

另請注意哗总,通過設(shè)置其widthheight屬性來更改<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>
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末望众,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子伞辛,更是在濱河造成了極大的恐慌烂翰,老刑警劉巖,帶你破解...
    沈念sama閱讀 223,207評論 6 521
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件蚤氏,死亡現(xiàn)場離奇詭異甘耿,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)竿滨,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 95,455評論 3 400
  • 文/潘曉璐 我一進(jìn)店門佳恬,熙熙樓的掌柜王于貴愁眉苦臉地迎上來捏境,“玉大人,你說我怎么就攤上這事毁葱〉嫜裕” “怎么了?”我有些...
    開封第一講書人閱讀 170,031評論 0 366
  • 文/不壞的土叔 我叫張陵倾剿,是天一觀的道長筷频。 經(jīng)常有香客問我,道長前痘,這世上最難降的妖魔是什么凛捏? 我笑而不...
    開封第一講書人閱讀 60,334評論 1 300
  • 正文 為了忘掉前任,我火速辦了婚禮芹缔,結(jié)果婚禮上葵袭,老公的妹妹穿的比我還像新娘。我一直安慰自己乖菱,他們只是感情好坡锡,可當(dāng)我...
    茶點故事閱讀 69,322評論 6 398
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著窒所,像睡著了一般鹉勒。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上吵取,一...
    開封第一講書人閱讀 52,895評論 1 314
  • 那天禽额,我揣著相機(jī)與錄音,去河邊找鬼皮官。 笑死脯倒,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的捺氢。 我是一名探鬼主播藻丢,決...
    沈念sama閱讀 41,300評論 3 424
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼摄乒!你這毒婦竟也來了悠反?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 40,264評論 0 277
  • 序言:老撾萬榮一對情侶失蹤馍佑,失蹤者是張志新(化名)和其女友劉穎斋否,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體拭荤,經(jīng)...
    沈念sama閱讀 46,784評論 1 321
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡茵臭,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 38,870評論 3 343
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了舅世。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片旦委。...
    茶點故事閱讀 40,989評論 1 354
  • 序言:一個原本活蹦亂跳的男人離奇死亡奇徒,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出社证,到底是詐尸還是另有隱情逼龟,我是刑警寧澤,帶...
    沈念sama閱讀 36,649評論 5 351
  • 正文 年R本政府宣布追葡,位于F島的核電站腺律,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏宜肉。R本人自食惡果不足惜匀钧,卻給世界環(huán)境...
    茶點故事閱讀 42,331評論 3 336
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望谬返。 院中可真熱鬧之斯,春花似錦、人聲如沸遣铝。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,814評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽酿炸。三九已至瘫絮,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間填硕,已是汗流浹背麦萤。 一陣腳步聲響...
    開封第一講書人閱讀 33,940評論 1 275
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點兒被人妖公主榨干…… 1. 我叫王不留扁眯,地道東北人壮莹。 一個月前我還...
    沈念sama閱讀 49,452評論 3 379
  • 正文 我出身青樓,卻偏偏與公主長得像姻檀,于是被迫代替她去往敵國和親命满。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 45,995評論 2 361

推薦閱讀更多精彩內(nèi)容