├── .babelrc ├── jest.setup.js ├── __mocks__ ├── fileMock.js └── styleMock.js ├── next.config.js ├── components ├── Loading.js ├── App.module.css ├── CopyToClipboard.js ├── Notification.module.css ├── Presets.module.css ├── Bitmap.module.css ├── Icon.test.js ├── Icon.js ├── Notification.js ├── Presets.js ├── App.js └── Bitmap.js ├── .husky ├── pre-push └── pre-commit ├── public └── robot.png ├── .prettierrc.json ├── jsconfig.json ├── src ├── useForceUpdate.js ├── share-url-encoder.test.js ├── formatters.js ├── reducer.js ├── transforms.js └── share-url-encoder.js ├── .prettierignore ├── pages ├── index.js ├── share │ └── [...bitmapdata].js └── _app.js ├── .eslintrc.json ├── .gitignore ├── scripts └── generate-share-url-encoder-v2-data.js ├── README.md ├── jest.config.js ├── styles └── globals.css ├── package.json └── __fixtures__ └── share-url-encoder-v0.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["next/babel"] 3 | } 4 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom' 2 | -------------------------------------------------------------------------------- /__mocks__/fileMock.js: -------------------------------------------------------------------------------- 1 | module.exports = 'test-file-stub' 2 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | reactStrictMode: true, 3 | } 4 | -------------------------------------------------------------------------------- /components/Loading.js: -------------------------------------------------------------------------------- 1 | export const Loading = () =>
125 | Made by{' '} 126 | “Cowboy” Ben Alman for{' '} 127 | The Entire Robot. Source 128 | code on GitHub, patches welcome! 129 | {commitSha && ( 130 | <> 131 | {' '} 132 | Built from commit{' '} 133 | 134 | {commitSha.slice(0, 7)} 135 | 136 | . 137 | > 138 | )} 139 |
140 | > 141 | ) 142 | } 143 | -------------------------------------------------------------------------------- /src/share-url-encoder.js: -------------------------------------------------------------------------------- 1 | import { decode } from 'js-base64' 2 | import { numberArrayToBitmapArray } from 'components/Presets' 3 | 4 | const isDev = process.env.NODE_ENV === 'development' 5 | 6 | // I don't think this was ever released 7 | const v1 = (() => { 8 | const lengthMap = 9 | 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ23456789' 10 | 11 | const shareUrlStringToBitmapArray = (shareUrlString) => 12 | shareUrlString 13 | .replace(/([^01,])([01])/g, (_, prefix, char) => 14 | char.repeat(lengthMap.indexOf(prefix) + 3) 15 | ) 16 | .split(',') 17 | .map((rowStr) => rowStr.split('').map((char) => char === '1')) 18 | 19 | return { 20 | shareUrlStringToBitmapArray, 21 | } 22 | })() 23 | 24 | const v2 = (() => { 25 | // Based on https://stackoverflow.com/a/13408680 26 | const bitstringEncode = ({ name, width, height, bitString }) => { 27 | let chars = '' 28 | for (let i = 0; i < bitString.length; i += 10) { 29 | const bitChunk = bitString.slice(i, i + 10) 30 | const binaryStr = `0b${bitChunk}${'0'.repeat(10 - bitChunk.length)}` 31 | const base32str = Number(binaryStr).toString(32) 32 | chars += `0${base32str}`.slice(-2) 33 | } 34 | return [name, width, height, chars].join('/') 35 | } 36 | 37 | const bitstringDecode = (encodedData) => { 38 | const [name, widthStr, heightStr, chars] = encodedData.split('/') 39 | let bitString = '' 40 | for (let i = 0; i < chars.length; i += 2) { 41 | const charStr = chars.slice(i, i + 2) 42 | const binStr = parseInt(charStr, 32).toString(2) 43 | bitString += `${'0'.repeat(10 - binStr.length)}${binStr}` 44 | } 45 | const width = parseInt(widthStr, 10) 46 | const height = parseInt(heightStr, 10) 47 | bitString = bitString.slice(0, width * height) 48 | return { name, width, height, bitString } 49 | } 50 | 51 | const bitmapArrayToBitString = (bitmapArray) => 52 | bitmapArray 53 | .map((row) => row.map((pixel) => (pixel ? '1' : '0')).join('')) 54 | .join('') 55 | 56 | const bitStringToBitmapArray = ({ bitString, width }) => { 57 | let bitmapArray = [] 58 | for (let i = 0; i < bitString.length; i += width) { 59 | const bitChunk = bitString.slice(i, i + width) 60 | bitmapArray = [...bitmapArray, bitChunk.split('').map((s) => s === '1')] 61 | } 62 | return bitmapArray 63 | } 64 | 65 | const stateToShareUrlString = ({ name, width, height, bitmapArray }) => { 66 | const bitString = bitmapArrayToBitString(bitmapArray) 67 | return bitstringEncode({ name, width, height, bitString }) 68 | } 69 | 70 | const shareUrlStringToState = (shareUrlData) => { 71 | const { name, width, height, bitString } = bitstringDecode(shareUrlData) 72 | const bitmapArray = bitStringToBitmapArray({ bitString, width }) 73 | return { name, width, height, bitmapArray } 74 | } 75 | 76 | return { 77 | stateToShareUrlString, 78 | shareUrlStringToState, 79 | } 80 | })() 81 | 82 | export const getShareUrlData = v2.stateToShareUrlString 83 | 84 | export const getStateFromBitmapData = (bitmapData) => { 85 | const log = (...args) => isDev && console.log(...args) 86 | log('bitmapData', bitmapData) 87 | 88 | // v2 89 | try { 90 | const state = v2.shareUrlStringToState(bitmapData) 91 | if (state.bitmapArray) { 92 | log('getStateFromBitmapData v2') 93 | return state 94 | } 95 | } catch (err) { 96 | log('v2 error', err.message) 97 | } 98 | 99 | // v1 100 | try { 101 | const json = decode(bitmapData) 102 | const { name, bitmapV1 } = JSON.parse(json) 103 | if (bitmapV1) { 104 | log('getStateFromBitmapData v1') 105 | const bitmapArray = v1.shareUrlStringToBitmapArray(bitmapV1) 106 | return { name, bitmapArray } 107 | } 108 | } catch (err) { 109 | log('v1 error', err.message) 110 | } 111 | 112 | // v0 113 | try { 114 | const json = decode(bitmapData) 115 | const { name, array } = JSON.parse(json) 116 | if (array) { 117 | log('getStateFromBitmapData v0') 118 | const bitmapArray = numberArrayToBitmapArray(array) 119 | return { name, bitmapArray } 120 | } 121 | } catch (err) { 122 | log('v0 error', err.message) 123 | } 124 | 125 | const error = 'Unable to parse bitmap data' 126 | return { error } 127 | } 128 | -------------------------------------------------------------------------------- /__fixtures__/share-url-encoder-v0.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | { 3 | name: 'robot', 4 | shareUrl: 5 | 'eyJuYW1lIjoicm9ib3QiLCJhcnJheSI6WzY2LDEyNiwxMjksMTY1LDEyOSwxMjYsNjAsMjU1XX0=', 6 | bitmapStringArray: [ 7 | ' x x ', 8 | ' xxxxxx ', 9 | 'x x', 10 | 'x x x x', 11 | 'x x', 12 | ' xxxxxx ', 13 | ' xxxx ', 14 | 'xxxxxxxx', 15 | ], 16 | }, 17 | { 18 | name: 'ok', 19 | shareUrl: 20 | 'eyJuYW1lIjoib2siLCJhcnJheSI6WzEwMSwxNDksMTQ5LDE1MCwxNTAsMTQ5LDE0OSwxMDFdfQ==', 21 | bitmapStringArray: [ 22 | ' xx x x', 23 | 'x x x x', 24 | 'x x x x', 25 | 'x x xx ', 26 | 'x x xx ', 27 | 'x x x x', 28 | 'x x x x', 29 | ' xx x x', 30 | ], 31 | }, 32 | { 33 | name: 'number3', 34 | shareUrl: 35 | 'eyJuYW1lIjoibnVtYmVyMyIsImFycmF5IjpbMzAsMywzLDE0LDMsMywzLDMwXX0=', 36 | bitmapStringArray: [ 37 | ' xxxx ', 38 | ' xx', 39 | ' xx', 40 | ' xxx ', 41 | ' xx', 42 | ' xx', 43 | ' xx', 44 | ' xxxx ', 45 | ], 46 | }, 47 | { 48 | name: 'hello_world', 49 | shareUrl: 50 | 'eyJuYW1lIjoiaGVsbG9fd29ybGQiLCJhcnJheSI6WzIzNzE2NDIxNDk4Mzg4NiwyMzU1MTU0MTc0MTg0NDUsMjM1NTE1NDE3NDE4NDQ1LDIzNTUxNTQxNzQxODQ0NSwyNzE3OTkzMDExMzU1NjUsMjM1NTE1NDE5NTE1NTk3LDIzNTUxNTQyMDU5Njk0MSwyMzcyMDA3MjIyMDU0MjJdfQ==', 51 | bitmapStringArray: [ 52 | 'xx x xxxx xx xx xx xx x xx xxx xx xxx ', 53 | 'xx x xx xx xx xx x xx x xx x xx x xx xx x', 54 | 'xx x xx xx xx xx x xx x xx x xx x xx xx x', 55 | 'xx x xx xx xx xx x xx x xx x xx x xx xx x', 56 | 'xxxx xxx xx xx xx x xx x xx x xxx xx xx x', 57 | 'xx x xx xx xx xx x xxx x xx x xx x xx xx x', 58 | 'xx x xx xx xx xx x xxxxx xxxx xx x xx xx x', 59 | 'xx x xxxx xxx xxx xx xx x xx xx x xxx xxx ', 60 | ], 61 | }, 62 | { 63 | name: 'boxes', 64 | shareUrl: 65 | 'eyJuYW1lIjoiYm94ZXMiLCJhcnJheSI6WzI1NSwzMjUxMywxNjc2NSwyMzg3NywyMTg0NSwyMzg3NywxNjc2NSwzMjUxMywzMzAyMiw0ODc3MCw0MTY1OCw0MzY5MCw0MTY1OCw0ODc3MCwzMzAyMiw2NTI4MF19', 66 | bitmapStringArray: [ 67 | ' xxxxxxxx', 68 | ' xxxxxxx x', 69 | ' x x xxxxx x', 70 | ' x xxx x x x x', 71 | ' x x x x x x x x', 72 | ' x xxx x x x x', 73 | ' x x xxxxx x', 74 | ' xxxxxxx x', 75 | 'x xxxxxxx ', 76 | 'x xxxxx x x ', 77 | 'x x x x xxx x ', 78 | 'x x x x x x x x ', 79 | 'x x x x xxx x ', 80 | 'x xxxxx x x ', 81 | 'x xxxxxxx ', 82 | 'xxxxxxxx ', 83 | ], 84 | }, 85 | { 86 | name: 'big_boxes', 87 | shareUrl: 88 | 'eyJuYW1lIjoiYmlnX2JveGVzIiwiYXJyYXkiOlsyODE0NzQ5NTk5MzM0NDAsMTQwNzM3NTEzNTIxMTUwLDIxMTEwNjE4NjM5NTY1MCwxNzU5MjE5NDg1MjQ1NDYsMTkzNTEzODY3MTgyMDY2LDE4NDcxODMxMDUwNjUxNCwxODkxMTUyODMyNzU3OTQsMTg2OTE4NDA3NjM0ODM0LDE4NzIwMTg3NTQxMTA5MCwxODc0NTk1NzM0NDg4NTAsMTg3NDcyNDU4MzY2MDk4LDE4NzYyNzA3NzE4MjYxMCwxODc0NzI0NTgzNTk5NTQsMTg3NDU5NTczNDU4MDY2LDE4NzE3NjEwNTYxNTUwNiwxODgwMTM2MjQyMzgyMjYsMTg2OTE4NDA3NTg1NjgyLDE4OTExNTI4MzM0MTMzMCwxODQ3MTgzMTA1NzIwNTAsMTkzNTEzODY2Nzg4ODUwLDE3NTkyMTk0OTA0ODgzNCwyMTExMDYxODY5MTk5MzgsMTQwNzM3NTEwMzc1NDIyLDI4MTQ3NDk1OTkzMzQ0MCwxNjc3NzIxNSw4Mzg4NjA5LDcwMzY4Njg5NjUxNzA5LDM1MTg0NDQ5NjgzNDYxLDM1MTg0NDUwNzMyMDIxLDQzOTgwMDA2MzU5MDYxLDM5NTgzMDMzODUxODYxLDQxNjc2ODMwMjc3NzE3LDQxNTIyMjExNDgwOTE3LDQxMjM4NzQzNjUxNjY5LDQwNzg3NzcyMDg1NTg5LDM5ODQwNzMxNzk3MDc3LDM5ODQwNzMxNzk2ODIxLDQwNzg3NzcyMDg1NTg5LDQxMjM4NzQzNjUxNjY5LDQxNTIyMjExNDgwOTE3LDQxNjc2ODMwMjc3NzE3LDM5NTgzMDMzODUxODYxLDQzOTgwMDA2MzU5MDYxLDM1MTg0NDUwNzMyMDIxLDM1MTg0NDQ5NjgzNDYxLDcwMzY4Njg5NjUxNzA5LDgzODg2MDksMTY3NzcyMTVdfQ==', 89 | bitmapStringArray: [ 90 | 'xxxxxxxxxxxxxxxxxxxxxxxx ', 91 | 'x x xxxxxxxxxxxxxxxxxxxxxx ', 92 | 'x xxxxxxxxxxxxxxxxxxxx x x x ', 93 | 'x x x x x x ', 94 | 'x x xxxxxxxxxxxxxxxx x x x xxxxxxxxxxxxxxxx x ', 95 | 'x x x x x x x x x x ', 96 | 'x x x xxxxxxxxxxxx x x x x x x x ', 97 | 'x x x x x x x x x x xxxxxxxxxx x x ', 98 | 'x x x x x x x x x x x x x x x x ', 99 | 'x x x x xxxxxx x x x x x x x x x x ', 100 | 'x x x x x x x x x x x x x xxxx x x x ', 101 | 'x x x x x x x x x x x x x x x x x x x x ', 102 | 'x x x x x x x x x x x x x x x x x x ', 103 | 'x x x x xxxxxx x x x x x x x x x x x x ', 104 | 'x x x x xxxx x x x x x x x x x x x ', 105 | 'x x x x xxxxxxxx x x x x x x x x x x x ', 106 | 'x x x x x x x x x x x xxxxxxx x x ', 107 | 'x x x xxxxxxxxxxxx x x x x x x x x ', 108 | 'x x x x x x x x x x x ', 109 | 'x x xxxxxxxxxxxxxxxx x x x x xxxxxxxxxxxxx x ', 110 | 'x x x x x x x ', 111 | 'x xxxxxxxxxxxxxxxxxxxx x x x x ', 112 | 'x x x xxxxxxxxxxxxxxxxxxx ', 113 | 'xxxxxxxxxxxxxxxxxxxxxxxx ', 114 | ' xxxxxxxxxxxxxxxxxxxxxxxx', 115 | ' x x', 116 | ' xxxxxxxxxxxxxxxxxxxx x xxxxxxxxxxxxxxxxxxxx x', 117 | ' x x x x x x', 118 | ' x x x x xxxxxxxxxxxxxxxx x x', 119 | ' x xxxxxxxxxxxxxx x x x x x x x', 120 | ' x x x x x x x xxxxxxxxxxxx x x x', 121 | ' x x xxxx xxxx x x x x x x x x x x', 122 | ' x x xxx xxx x x x x x x xx x x x x x x', 123 | ' x x xx xx x x x x x x x x x x x x x x', 124 | ' x x x xx x x x x x x x x x x x x x x x', 125 | ' x x xxxx x x x x x x x x xx x x x x', 126 | ' x x xxxx x x x x x x x x x x x x x x', 127 | ' x x x xx x x x x x x x x x x x x x x x', 128 | ' x x xx xx x x x x x x x x x x x x x x', 129 | ' x x xxx xxx x x x x x x xx x x x x x x', 130 | ' x x xxxx xxxx x x x x x x x x x x', 131 | ' x x x x x x x xxxxxxxxxxxx x x x', 132 | ' x xxxxxxxxxxxxxx x x x x x x x', 133 | ' x x x x xxxxxxxxxxxxxxxx x x', 134 | ' x x x x x x', 135 | ' xxxxxxxxxxxxxxxxxxxx x xxxxxxxxxxxxxxxxxxxx x', 136 | ' x x', 137 | ' xxxxxxxxxxxxxxxxxxxxxxxx', 138 | ], 139 | }, 140 | ] 141 | -------------------------------------------------------------------------------- /components/Bitmap.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import Favicon from 'react-favicon' 3 | import cx from 'classnames' 4 | import { blockSize } from 'src/transforms' 5 | import { numberArrayToBitmapArray } from 'components/Presets' 6 | import { Icon } from 'components/Icon' 7 | 8 | import styles from './Bitmap.module.css' 9 | 10 | const getImageDataUrl = (width, height) => { 11 | const canvas = document.createElement('canvas') 12 | canvas.width = width 13 | canvas.height = height 14 | const ctx = canvas.getContext('2d') 15 | ctx.fillStyle = '#0f0' 16 | ctx.fillRect(0, 0, width, height) 17 | return canvas.toDataURL('image/png') 18 | } 19 | 20 | const faviconSize = 16 21 | 22 | const bgBlank = [ 23 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 24 | 0x00, 0x00, 0x00, 25 | ] 26 | const bgBorder = [ 27 | 0x3ffc, 0x4002, 0x8001, 0x8001, 0x8001, 0x8001, 0x8001, 0x8001, 0x8001, 28 | 0x8001, 0x8001, 0x8001, 0x8001, 0x8001, 0x4002, 0x3ffc, 29 | ] 30 | const bgMask = [ 31 | 0x7ffe, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 32 | 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0x7ffe, 33 | ] 34 | 35 | const ButtonGroup = ({ name, children }) => ( 36 | 37 | {name && {name}} 38 | {children} 39 | 40 | ) 41 | 42 | export const Bitmap = ({ 43 | bitmapArray, 44 | width, 45 | height, 46 | scale, 47 | onChangeBitmap, 48 | onChangeScale, 49 | }) => { 50 | const [dragState, setDragState] = React.useState(null) 51 | const imageDataUrl = React.useMemo( 52 | () => getImageDataUrl(faviconSize, faviconSize), 53 | [] 54 | ) 55 | 56 | const setScale = (multiplier) => () => { 57 | onChangeScale(multiplier * scale) 58 | } 59 | 60 | const favicon = (canvas, ctx) => { 61 | const size = faviconSize 62 | const arr = new Uint8ClampedArray(4 * size * size) 63 | const setPixel = (x, y, state) => { 64 | const i = (y * size + x) * 4 65 | arr[i + 0] = state ? 255 : 0 // R value 66 | arr[i + 1] = state ? 255 : 0 // G value 67 | arr[i + 2] = state ? 255 : 0 // B value 68 | } 69 | const setAlpha = (x, y, alpha) => { 70 | const i = (y * size + x) * 4 71 | arr[i + 3] = alpha ? 255 : 0 72 | } 73 | 74 | const forEachPixel = (bitmapArray, fn) => 75 | bitmapArray.forEach((row, y) => 76 | row.forEach((pixel, x) => fn(x, y, pixel)) 77 | ) 78 | 79 | const bg = width <= 12 && height <= 12 ? bgBorder : bgBlank 80 | forEachPixel(numberArrayToBitmapArray(bg), setPixel) 81 | forEachPixel(numberArrayToBitmapArray(bgMask), setAlpha) 82 | 83 | forEachPixel(bitmapArray, (x, y, pixel) => { 84 | if (x < size && y < size) { 85 | setPixel( 86 | x + Math.max(0, Math.floor((size - width) / 2)), 87 | y + Math.max(0, Math.floor((size - height) / 2)), 88 | pixel 89 | ) 90 | } 91 | }) 92 | 93 | const imageData = new ImageData(arr, size, size) 94 | ctx.putImageData(imageData, 0, 0) 95 | } 96 | 97 | const mouseDown = (x, y) => (event) => { 98 | event.preventDefault() 99 | togglePixelOnClick(x, y) 100 | } 101 | 102 | const mouseEnter = (x, y) => () => { 103 | if (dragState !== null) { 104 | setPixelOnDrag(x, y) 105 | } 106 | } 107 | 108 | React.useEffect(() => { 109 | const handler = () => { 110 | setDragState(null) 111 | } 112 | document.addEventListener('mouseup', handler, false) 113 | return () => { 114 | document.removeEventListener('mouseup', handler, false) 115 | } 116 | }) 117 | 118 | const not = (x) => !x 119 | const returnFalse = () => false 120 | 121 | const map = (fn) => (arr) => arr.map(fn) 122 | const shift = (i) => (arr) => [...arr.slice(i), ...arr.slice(0, i)] 123 | const add = (fn) => (arr) => [...arr, ...fn()] 124 | const remove = (n) => (arr) => arr.slice(0, Math.max(n, arr.length - n)) 125 | const setItem = (fn, i) => (arr) => 126 | [...arr.slice(0, i), fn(arr[i]), ...arr.slice(i + 1)] 127 | const array = 128 | (length, fn = returnFalse) => 129 | () => 130 | Array.from({ length }, fn) 131 | 132 | const updater = 133 | (fn) => 134 | (...a) => 135 | (...b) => { 136 | onChangeBitmap(fn(...a, ...b)(bitmapArray)) 137 | } 138 | 139 | const decW = updater((n) => map(remove(n))) 140 | const incW = updater((n) => map(add(array(n)))) 141 | const decH = updater((n) => remove(n)) 142 | const incH = updater((n) => add(array(n, array(width)))) 143 | 144 | const shiftY = updater((i) => shift(i)) 145 | const shiftX = updater((i) => map(shift(i))) 146 | 147 | const perPixel = updater((fn) => map(map(fn))) 148 | const clear = perPixel(returnFalse) 149 | const invert = perPixel(not) 150 | 151 | const setPixel = updater((fn, x, y) => setItem(setItem(fn, x), y)) 152 | const togglePixelOnClick = setPixel((state) => { 153 | setDragState(!state) 154 | return !state 155 | }) 156 | const setPixelOnDrag = setPixel(() => dragState) 157 | 158 | return ( 159 | <> 160 || 253 | ))} 254 | |