From 4a9b96e5cc8bf822bf3b2860b175f446cb45f98a Mon Sep 17 00:00:00 2001 From: Martin Ashby Date: Fri, 1 Jun 2018 19:33:11 +0100 Subject: Implemented animated GIFs --- src/Actions.js | 26 ++++++++++++-- src/App.js | 85 ++++++++++++++++++++++++++++++++-------------- src/FrameControl.js | 56 ++++++++++++++++++++++++++++++ src/Toolkit.js | 1 - src/Utils.js | 83 +++++++++++++++++++++++++++++++++++++++++++- src/fontawesome-all.min.js | 5 --- 6 files changed, 220 insertions(+), 36 deletions(-) create mode 100644 src/FrameControl.js delete mode 100644 src/fontawesome-all.min.js (limited to 'src') diff --git a/src/Actions.js b/src/Actions.js index 1f4e7cf..9424c51 100644 --- a/src/Actions.js +++ b/src/Actions.js @@ -3,6 +3,8 @@ const SET_PIXEL = 'SET_PIXEL' const CLEAR = 'CLEAR' const SAVE = 'SAVE' const LOAD = 'LOAD' +const ADD_FRAME = "ADD_FRAME" +const REMOVE_FRAME = "REMOVE_FRAME" function sendAction(websocket, action) { let actionStr = JSON.stringify(action) @@ -23,14 +25,15 @@ function load(websocket, saveName) { }) } -function setPixel(websocket, x, y, r, g, b) { +function setPixel(websocket, x, y, r, g, b, frame) { sendAction(websocket, { type: SET_PIXEL, x: x, y: y, r: r, g: g, - b: b + b: b, + frame: frame }) } @@ -42,10 +45,27 @@ function noop(websocket) { sendAction(websocket, { type: NO_OP }) } +function addFrame(websocket, frame = 1, delay = 50) { + sendAction(websocket, { + type: ADD_FRAME, + frame: frame, + delay: delay + }) +} + +function removeFrame(websocket, frame = 1) { + sendAction(websocket, { + type: REMOVE_FRAME, + frame: frame + }) +} + export { setPixel, clear, noop, save, - load + load, + addFrame, + removeFrame } \ No newline at end of file diff --git a/src/App.js b/src/App.js index 7083ba6..bbaf5e3 100644 --- a/src/App.js +++ b/src/App.js @@ -1,7 +1,7 @@ import React, { Component } from 'react' import './App.css' -import { rgb, xy, findContiguousPixels, getPixel, rotatePixelsClock, rotatePixelsCounterClock } from './Utils' -import { setPixel, clear, noop, save, load } from './Actions' +import { rgb, xy, findContiguousPixels, getPixel, rotatePixelsClock, rotatePixelsCounterClock, readGifFrames } from './Utils' +import { setPixel, clear, noop, save, load, addFrame, removeFrame } from './Actions' import Palette from './Palette' import PaintArea from './PaintArea' import Toolkit from './Toolkit' @@ -9,48 +9,68 @@ import ColorIndicator from './ColorIndicator' import ConnectedIndicator from './ConnectedIndicator' import LoadDialog from './LoadDialog' import SaveDialog from './SaveDialog' +import FrameControl from './FrameControl' import { Timeline } from 'react-twitter-widgets' +import '@fortawesome/fontawesome-free-webfonts/css/fontawesome.css' +import '@fortawesome/fontawesome-free-webfonts/css/fa-solid.css' +import '@fortawesome/fontawesome-free-webfonts/css/fa-regular.css' +import '@fortawesome/fontawesome-free-webfonts/css/fa-brands.css' const tools = [ { name: "paint", icon: "fas fa-pencil-alt", - action: function (x, y) { + action: function (x, y, frame) { let { r, g, b } = rgb(this.state.selectedColor) - setPixel(this._websocket, x, y, r, g, b) + setPixel(this._websocket, x, y, r, g, b, frame) } }, { name: "fill", icon: "fab fa-bitbucket", - action: function (x, y) { - let pixelsToColor = findContiguousPixels(x, y, this.state.pixels) + action: function (x, y, frame) { + let pixelsToColor = findContiguousPixels(x, y, this.state.frames[frame]) pixelsToColor.forEach((coord) => { let px = { ...xy(coord), ...rgb(this.state.selectedColor) } - setPixel(this._websocket, px.x, px.y, px.r, px.g, px.b) + setPixel(this._websocket, px.x, px.y, px.r, px.g, px.b, frame) }) } }, { name: "erase", icon: "fas fa-eraser", - action: function (x, y) { - setPixel(this._websocket, x, y, 0, 0, 0) + action: function (x, y, frame) { + setPixel(this._websocket, x, y, 0, 0, 0, frame) }, }, { name: "pick", icon: "fas fa-eye-dropper", - action: function (x, y) { + action: function (x, y, frame) { let color = getPixel(x, y, this.state.pixels) this.setState({ selectedColor: color }) }, }, + { + name: "add frame", + icon: "far fa-plus-square", + onSelect: function () { + addFrame(this._websocket, this.state.selectedFrame+1, 50) + } + }, + { + name: "remove frame", + icon: "far fa-minus-square", + onSelect: function () { + removeFrame(this._websocket, this.state.selectedFrame) + } + }, { name: "rotate-clockwise", icon: "fas fa-redo", onSelect: function () { - let newPixels = rotatePixelsClock(this.state.pixels) + let pixels = this.state.frames[this.state.selectedFrame] + let newPixels = rotatePixelsClock(pixels) this._setAllPixels(newPixels) } }, @@ -58,18 +78,11 @@ const tools = [ name: "rotate-anticlockwise", icon: "fas fa-undo", onSelect: function () { - let newPixels = rotatePixelsCounterClock(this.state.pixels) + let pixels = this.state.frames[this.state.selectedFrame] + let newPixels = rotatePixelsCounterClock(pixels) this._setAllPixels(newPixels) } }, - // { - // name: "lighten", - // icon: "far fa-sun" - // }, - // { - // name: "darken", - // icon: "fas fa-sun" - // }, { name: "save", icon: "fas fa-save", @@ -100,14 +113,18 @@ class App extends Component { this.state = { connected: false, // Data from server - pixels: [], + frames: [], saves: [], + // Local data + selectedFrame: 0, selectedColor: [0, 0, 0], selectedTool: tools[0], showingSave: false, showingLoad: false, } + + this._applyTool = this._applyTool.bind(this) this._selectTool = this._selectTool.bind(this) this._connectWebsocket = this._connectWebsocket.bind(this) @@ -121,9 +138,17 @@ class App extends Component { } _onMessage({ data }) { - let state = JSON.parse(data) + let { imageData, saves } = JSON.parse(data) + let frames = readGifFrames(imageData) + + let selectedFrame = this.state.selectedFrame >= frames.length ? + frames.length - 1 : + this.state.selectedFrame + this.setState({ - ...state // Includes pixels and saves + frames: frames, + saves: saves, + selectedFrame: selectedFrame, }) } @@ -162,7 +187,8 @@ class App extends Component { if (!action) { return } - action.bind(this)(x, y) + let selectedFrame = this.state.selectedFrame + action.bind(this)(x, y, selectedFrame) } _selectTool(tool) { @@ -191,12 +217,15 @@ class App extends Component { for (var y = 0; y < height; y++) { let px = getPixel(x, y, newPixels) let { r, g, b } = rgb(px) - setPixel(this._websocket, x, y, r, g, b) + let selectedFrame = this.state.selectedFrame + setPixel(this._websocket, x, y, r, g, b, selectedFrame) } } } render() { + let frame = this.state.selectedFrame + let pixels = this.state.frames[frame] || [] return (
@@ -204,9 +233,13 @@ class App extends Component {
+ this.setState({selectedFrame: frame}) }/>
diff --git a/src/FrameControl.js b/src/FrameControl.js new file mode 100644 index 0000000..45db5d4 --- /dev/null +++ b/src/FrameControl.js @@ -0,0 +1,56 @@ +import React, { Component } from 'react' +import { rgb, getPixel } from './Utils' + +class FramePreview extends Component { + render() { + let width = this.props.pixels.length + let height = this.props.pixels[0].length + + let rows = [] + for (var y=height-1; y>=0; y--) { + let cells = [] + for (var x=0; x) + } + rows.push({cells}) + } + + let bgColor = this.props.selected ? "red" : "grey" + return {rows}
+ } +} + +export default class FrameControl extends Component { + // frames: [] + // selectedFrame: number + render() { + // A series of divs, 1 per frame, + let frames = this.props.frames + let selectedFrame = this.props.selectedFrame + let framePreviews = frames.map((frame, ix) => +
+ Frame {ix+1} + this.props.onFrameSelected(ix)}/> +
) + return
{framePreviews}
+ } +} + +const styles = { + previewcontainer: { + }, + previewTable: { + }, + previewPixel: { + width: "4px", + height: "4px", + } +} \ No newline at end of file diff --git a/src/Toolkit.js b/src/Toolkit.js index b12f418..e29d71d 100644 --- a/src/Toolkit.js +++ b/src/Toolkit.js @@ -1,5 +1,4 @@ import React, { Component } from 'react' -import './fontawesome-all.min.js' class ToolKitItem extends Component { render() { diff --git a/src/Utils.js b/src/Utils.js index 7432c49..eeeca0d 100644 --- a/src/Utils.js +++ b/src/Utils.js @@ -1,3 +1,6 @@ +import base64js from 'base64-js' +import omggif from 'omggif' + /** * Converts RGB array to named object * @param {[]} item, a length 3 array containing 8 bit RGB value @@ -21,6 +24,11 @@ function xy(item) { } } +/** + * Compares colors. + * Colors are either 3-element arrays [r, g, b] + * Or objects with .r .g .b members + */ function colorEqual(item1, item2) { if (!item1 || !item2) { return false @@ -36,6 +44,11 @@ function colorEqual(item1, item2) { && item1.b === item2.b } +/** + * Compares 2 coordinate values + * @param { [x, y] or {x: y:} } item1 + * @param { [x, y] or {x: y:} } item2 + */ function coordsEqual(item1, item2) { if (!item1 || !item2) { return false @@ -51,6 +64,13 @@ function coordsEqual(item1, item2) { && item1.y === item2.y } +/** + * If I ever want to change the pixel format... + * I should really route all access to pixels through here. + * @param {number} x + * @param {number} y + * @param {[][][]} pixels + */ function getPixel(x, y, pixels) { let column = pixels[x] if (!column) { @@ -59,6 +79,18 @@ function getPixel(x, y, pixels) { return column[y] } +/** + * Finds contiguous regions of pixels of the same color. + * Returns an array of the x & y coordinates of every pixel in the region. + * Doesn't jump corners: only pixels that share a side are considered to + * join up. + * + * @param {number} x + * @param {number} y + * @param {[][]]} pixels + * @param {[]]} targetColor + * @param {[][]]} contiguousPixels + */ function findContiguousPixels(x, y, pixels, targetColor = getPixel(x, y, pixels), contiguousPixels=[[x, y]]) { let adjescent = [ [x-1, y], @@ -111,6 +143,14 @@ function rotatePixelsClock(pixels) { return transformPixels(pixels, rotateClock) } +/** + * Apply an arbitrary transform to some pixels. + * Does not modify the original pixels, just returns the new ones + * + * @param {[][][]]} pixels + * @param {(x, y, width, height) => { newx: newy: }} transform + * @returns {[][][]} newPixels + */ function transformPixels(pixels, transform) { let width = pixels.length let height = pixels[0].length @@ -133,6 +173,46 @@ function transformPixels(pixels, transform) { return newPixels } +/** + * Reads all frames from a GIF image, returns them as 3d arrays (x, y, color component) + * @param {string} base64GifData The GIF image encoded as base64 + */ +function readGifFrames(base64GifData) { + let imageBytes = base64js.toByteArray(base64GifData) + let gifReader = new omggif.GifReader(imageBytes) + + let { width, height } = gifReader + let numFrames = gifReader.numFrames() + let frames = [] + + for (var i=0; i>>0;h--;)l[h]=c[h];return l}function X(c){return c.classList?D(c.classList):(c.getAttribute("class")||"").split(" ").filter(function(c){return c})}function Y(c,l){var h,v=l.split("-"),z=v[0],e=v.slice(1).join("-");return z!==c||""===e||(h=e,~d.indexOf(h))?null:e}function U(c){return(""+c).replace(/&/g,"&").replace(/"/g,""").replace(/'/g,"'").replace(//g,">")}function K(h){return Object.keys(h||{}).reduce(function(c,l){return c+(l+": ")+h[l]+";"},"")}function G(c){return c.size!==I.size||c.x!==I.x||c.y!==I.y||c.rotate!==I.rotate||c.flipX||c.flipY}function J(c){var l=c.transform,h=c.containerWidth,v=c.iconWidth;return{outer:{transform:"translate("+h/2+" 256)"},inner:{transform:"translate("+32*l.x+", "+32*l.y+") "+" "+("scale("+l.size/16*(l.flipX?-1:1)+", "+l.size/16*(l.flipY?-1:1)+") ")+" "+("rotate("+l.rotate+" 0 0)")},path:{transform:"translate("+v/2*-1+" -256)"}}}var Q={x:0,y:0,width:"100%",height:"100%"},Z=function(c){var l=c.children,h=c.attributes,v=c.main,z=c.mask,e=c.transform,m=v.width,a=v.icon,s=z.width,t=z.icon,f=J({transform:e,containerWidth:s,iconWidth:m}),M={tag:"rect",attributes:S({},Q,{fill:"white"})},r={tag:"g",attributes:S({},f.inner),children:[{tag:"path",attributes:S({},a.attributes,f.path,{fill:"black"})}]},H={tag:"g",attributes:S({},f.outer),children:[r]},i="mask-"+B(),n="clip-"+B(),V={tag:"defs",children:[{tag:"clipPath",attributes:{id:n},children:[t]},{tag:"mask",attributes:S({},Q,{id:i,maskUnits:"userSpaceOnUse",maskContentUnits:"userSpaceOnUse"}),children:[M,H]}]};return l.push(V,{tag:"rect",attributes:S({fill:"currentColor","clip-path":"url(#"+n+")",mask:"url(#"+i+")"},Q)}),{children:l,attributes:h}},$=function(c){var l=c.children,h=c.attributes,v=c.main,z=c.transform,e=K(c.styles);if(0"+m.map(uc).join("")+""}var dc=function(){};function pc(c){return"string"==typeof(c.getAttribute?c.getAttribute(g):null)}var bc={replace:function(c){var l=c[0],h=c[1].map(function(c){return uc(c)}).join("\n");if(l.parentNode&&l.outerHTML)l.outerHTML=h+(O.keepOriginalSource&&"svg"!==l.tagName.toLowerCase()?"\x3c!-- "+l.outerHTML+" --\x3e":"");else if(l.parentNode){var v=document.createElement("span");l.parentNode.replaceChild(v,l),v.outerHTML=h}},nest:function(c){var l=c[0],h=c[1];if(~X(l).indexOf(O.replacementClass))return bc.replace(c);var v=new RegExp(O.familyPrefix+"-.*");delete h[0].attributes.style;var z=h[0].attributes.class.split(" ").reduce(function(c,l){return l===O.replacementClass||l.match(v)?c.toSvg.push(l):c.toNode.push(l),c},{toNode:[],toSvg:[]});h[0].attributes.class=z.toSvg.join(" ");var e=h.map(function(c){return uc(c)}).join("\n");l.setAttribute("class",z.toNode.join(" ")),l.setAttribute(g,""),l.innerHTML=e}};function gc(h,c){var v="function"==typeof c?c:dc;0===h.length?v():(a.requestAnimationFrame||function(c){return c()})(function(){var c=!0===O.autoReplaceSvg?bc.replace:bc[O.autoReplaceSvg]||bc.replace,l=sc.begin("mutate");h.map(c),l(),v()})}var wc=!1;var yc=null;var Sc=function(c){var l=c.getAttribute("style"),h=[];return l&&(h=l.split(";").reduce(function(c,l){var h=l.split(":"),v=h[0],z=h.slice(1);return v&&0li{position:relative}.fa-li{left:-2em;position:absolute;text-align:center;width:2em;line-height:inherit}.fa-border{border:solid .08em #eee;border-radius:.1em;padding:.2em .25em .15em}.fa-pull-left{float:left}.fa-pull-right{float:right}.fa.fa-pull-left,.fab.fa-pull-left,.fal.fa-pull-left,.far.fa-pull-left,.fas.fa-pull-left{margin-right:.3em}.fa.fa-pull-right,.fab.fa-pull-right,.fal.fa-pull-right,.far.fa-pull-right,.fas.fa-pull-right{margin-left:.3em}.fa-spin{-webkit-animation:fa-spin 2s infinite linear;animation:fa-spin 2s infinite linear}.fa-pulse{-webkit-animation:fa-spin 1s infinite steps(8);animation:fa-spin 1s infinite steps(8)}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0);transform:rotate(0)}100%{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0);transform:rotate(0)}100%{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}.fa-rotate-90{-webkit-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{-webkit-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{-webkit-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{-webkit-transform:scale(-1,1);transform:scale(-1,1)}.fa-flip-vertical{-webkit-transform:scale(1,-1);transform:scale(1,-1)}.fa-flip-horizontal.fa-flip-vertical{-webkit-transform:scale(-1,-1);transform:scale(-1,-1)}:root .fa-flip-horizontal,:root .fa-flip-vertical,:root .fa-rotate-180,:root .fa-rotate-270,:root .fa-rotate-90{-webkit-filter:none;filter:none}.fa-stack{display:inline-block;height:2em;position:relative;width:2em}.fa-stack-1x,.fa-stack-2x{bottom:0;left:0;margin:auto;position:absolute;right:0;top:0}.svg-inline--fa.fa-stack-1x{height:1em;width:1em}.svg-inline--fa.fa-stack-2x{height:2em;width:2em}.fa-inverse{color:#fff}.sr-only{border:0;clip:rect(0,0,0,0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.sr-only-focusable:active,.sr-only-focusable:focus{clip:auto;height:auto;margin:0;overflow:visible;position:static;width:auto}";if("fa"!==l||h!==c){var z=new RegExp("\\.fa\\-","g"),e=new RegExp("\\."+c,"g");v=v.replace(z,"."+l+"-").replace(e,"."+h)}return v};var Qc=function(){function c(){w(this,c),this.definitions={}}return y(c,[{key:"add",value:function(){for(var l=this,c=arguments.length,h=Array(c),v=0;v