= ({ onChange }) => {
109 | const [data, setData] = useState<
110 | { file: File; imageUrl: string; imageData: ImageData } | undefined
111 | >();
112 |
113 | const [componentX, setComponentX] = useState(4);
114 | const [componentY, setComponentY] = useState(4);
115 |
116 | const blurhash = useMemo(
117 | () =>
118 | data
119 | ? encode(
120 | data.imageData.data,
121 | data.imageData.width,
122 | data.imageData.height,
123 | componentX,
124 | componentY,
125 | )
126 | : undefined,
127 | [data, componentX, componentY],
128 | );
129 |
130 | useEffect(() => onChange(blurhash), [blurhash]);
131 |
132 | const handleFileChange = useCallback((file: File) => {
133 | const imageUrl = URL.createObjectURL(file);
134 |
135 | (async () => {
136 | const img = await loadImage(imageUrl);
137 | const clampedSize = getClampedSize(img.width, img.height, 64);
138 | const imageData = getImageData(img, clampedSize.width, clampedSize.height);
139 |
140 | setData({ file, imageUrl, imageData });
141 | })();
142 | }, []);
143 |
144 | return (
145 |
146 |
147 |
148 | {!!data && (
149 | <>
150 |
151 | {data.file.name}
152 | >
153 | )}
154 |
155 |
156 |
157 |
158 | Encoding settings
159 |
160 |
161 | setComponentX(Number(e.target.value))}
166 | />
167 |
168 |
169 |
170 | setComponentY(Number(e.target.value))}
175 | />
176 |
177 |
178 |
179 | {!!blurhash && (
180 |
181 | Blurhash result
182 |
183 | {blurhash}
184 |
185 | )}
186 |
187 | );
188 | };
189 |
190 | export default BlurhashImageEncoder;
191 |
--------------------------------------------------------------------------------
/demo/Button.tsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const Button = styled.button`
4 | background: none;
5 | outline: none;
6 |
7 | box-sizing: border-box;
8 | display: inline-flex;
9 | align-items: center;
10 | justify-content: center;
11 | border-radius: 4px;
12 | background-color: #0019ff;
13 | color: white;
14 | border: none;
15 | padding: 8px 24px;
16 | min-height: 42px;
17 | font-size: 15px;
18 | font-weight: 500;
19 | cursor: pointer;
20 |
21 | &:hover,
22 | &:focus {
23 | background-color: #3d50ff;
24 | }
25 |
26 | &:active {
27 | background-color: #041ae5;
28 | }
29 | `;
30 |
31 | export default Button;
32 |
--------------------------------------------------------------------------------
/demo/Demo.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useMemo } from 'react';
2 | import { hot } from 'react-hot-loader/root';
3 | import styled from 'styled-components';
4 | import { isBlurhashValid } from 'blurhash';
5 |
6 | import { Blurhash, BlurhashCanvas } from '../src';
7 | import RadioInput from './RadioInput';
8 | import RangeInput from './RangeInput';
9 | import Setting from './Setting';
10 | import BlurhashImageEncoder from './BlurhashImageEncoder';
11 |
12 | const Root = styled.div`
13 | margin: 0 auto;
14 | max-width: 1024px;
15 | font-family: sans-serif;
16 | `;
17 |
18 | const BlurhashError = styled.div`
19 | margin-top: 10px;
20 | color: #cc4260;
21 | font-weight: 500;
22 | font-size: 0.9em;
23 | `;
24 |
25 | const Heading1 = styled.h1`
26 | margin: 24px 0;
27 | `;
28 |
29 | const Heading2 = styled.h2`
30 | font-size: 1.4em;
31 | `;
32 |
33 | const Code = styled.pre`
34 | margin: 0;
35 | padding: 0;
36 | display: inline-block;
37 | font-family: monospace, monospace;
38 | `;
39 |
40 | const Separator = styled.hr`
41 | height: 1px;
42 | background-color: #e0e0e0;
43 | border: none;
44 | margin: 24px 0;
45 | `;
46 |
47 | const SettingsContainer = styled.div`
48 | padding: 15px;
49 | display: inline-block;
50 | background-color: #f5f5f5;
51 | border: 1px solid #e4e4e4;
52 | border-radius: 4px;
53 | `;
54 |
55 | const BlurhashContainer = styled.div`
56 | margin-top: 18px;
57 | `;
58 |
59 | const TextInput = styled.input.attrs({
60 | type: 'test',
61 | autoComplete: 'off',
62 | autoCapitalize: 'off',
63 | autoCorrect: 'off',
64 | spellCheck: false,
65 | })`
66 | white-space: pre;
67 | font-size: 1em;
68 | font-family: monospace;
69 | padding: 8px;
70 | border-radius: 4px;
71 | border: 2px solid #d8d8d8;
72 | width: 100%;
73 | box-sizing: border-box;
74 |
75 | outline: none;
76 |
77 | &:focus {
78 | border-color: #8ab7ea;
79 | }
80 | `;
81 |
82 | const ModeSelect = styled.div`
83 | margin: 15px 0;
84 | `;
85 |
86 | const StyledRadioInput = styled(RadioInput)`
87 | & + & {
88 | margin-left: 14px;
89 | }
90 | `;
91 |
92 | const Hint = styled.div`
93 | font-size: 0.8em;
94 | color: #999;
95 | margin: 8px 0;
96 | `;
97 |
98 | const Footer = styled.footer`
99 | border-top: 1px solid #f2f2f2;
100 | margin-top: 40px;
101 | padding: 15px 10px;
102 | text-align: center;
103 | font-size: 0.9em;
104 |
105 | ul {
106 | list-style: none;
107 | white-space: nowrap;
108 | }
109 |
110 | li {
111 | position: relative;
112 | display: inline;
113 | }
114 |
115 | li + li {
116 | margin-left: 24px;
117 | }
118 |
119 | li + li:after {
120 | position: absolute;
121 | top: 0px;
122 | left: -15px;
123 | content: '·';
124 | color: #c8c8c8;
125 | }
126 |
127 | a {
128 | color: #c8c8c8;
129 | text-decoration: none;
130 |
131 | &:hover {
132 | color: #3d50ff;
133 | }
134 |
135 | &:active {
136 | color: #041ae5;
137 | }
138 | }
139 | `;
140 |
141 | const BlurhashDemo = ({ hash }: { hash: string }) => {
142 | const [size, setSize] = useState(256);
143 | const [resolution, setResolution] = useState(32);
144 | const [punch, setPunch] = useState(1);
145 |
146 | return (
147 |
148 |
149 |
150 | setSize(Number(e.target.value))}
156 | />
157 |
158 |
159 |
160 | setResolution(Number(e.target.value))}
166 | />
167 |
168 |
169 |
170 | setPunch(Number(e.target.value))}
176 | />
177 |
178 |
179 |
180 |
181 |
189 |
190 |
191 | );
192 | };
193 |
194 | const BlurhashCanvasDemo = ({ hash }: { hash: string }) => {
195 | const [size, setSize] = useState(256);
196 | const [punch, setPunch] = useState(1);
197 |
198 | return (
199 |
200 |
201 |
202 | setSize(Number(e.target.value))}
208 | />
209 |
210 |
211 |
212 | setPunch(Number(e.target.value))}
218 | />
219 |
220 |
221 |
222 |
223 |
224 |
225 |
226 | );
227 | };
228 |
229 | const Demo = () => {
230 | const [mode, setMode] = useState<'hash' | 'image'>('hash');
231 | const [hashInput, setHashInput] = useState('LEHV6nWB2yk8pyo0adR*.7kCMdnj');
232 | const [encodedHash, setEncodedHash] = useState('');
233 | const hash = mode === 'hash' ? hashInput : encodedHash;
234 | const blurhashValid = useMemo(() => isBlurhashValid(hash), [hash]);
235 |
236 | return (
237 |
238 | react-blurhash demo
239 |
240 |
241 |
242 | setMode(e.target.value as 'hash'),
246 | value: 'hash',
247 | checked: mode === 'hash',
248 | }}
249 | />
250 | setMode(e.target.value as 'image'),
254 | value: 'image',
255 | checked: mode === 'image',
256 | }}
257 | />
258 |
259 |
260 | {mode === 'hash' && (
261 | setHashInput(e.target.value.trim())} />
262 | )}
263 |
264 | {mode === 'image' && (
265 | <>
266 | Note: encoding is done in the browser only (no server involved)!
267 | setEncodedHash(hash)} value={encodedHash} />
268 | >
269 | )}
270 |
271 | {hash && !blurhashValid.result && (
272 |
273 | Invalid blurhash - {blurhashValid.errorReason}
274 |
275 | )}
276 |
277 | {blurhashValid.result && (
278 | <>
279 |
280 |
281 | {''}
282 |
283 |
284 |
285 |
286 | {''}
287 |
288 |
289 | >
290 | )}
291 |
292 |
311 |
312 | );
313 | };
314 |
315 | export default hot(Demo);
316 |
--------------------------------------------------------------------------------
/demo/FileInput.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback, useState } from 'react';
2 | import styled from 'styled-components';
3 |
4 | type Props = {
5 | children?: React.ReactNode;
6 | className?: string;
7 | id?: string;
8 | onChange: (file: File) => void;
9 | showText: boolean;
10 | };
11 |
12 | const Text = styled.span`
13 | position: absolute;
14 | top: 0;
15 | bottom: 0;
16 | left: 0;
17 | right: 0;
18 | display: flex;
19 | align-items: center;
20 | justify-content: center;
21 | color: #777;
22 | `;
23 |
24 | const Root = styled.label`
25 | position: relative;
26 | display: flex;
27 | align-items: center;
28 | justify-content: center;
29 | cursor: pointer;
30 |
31 | &:hover ${Text}, &:focus ${Text} {
32 | color: #3d50ff;
33 | }
34 |
35 | &:active ${Text} {
36 | color: #041ae5;
37 | }
38 | `;
39 |
40 | const Input = styled.input`
41 | width: 0.1px;
42 | height: 0.1px;
43 | opacity: 0;
44 | overflow: hidden;
45 | position: absolute;
46 | z-index: -1;
47 | `;
48 |
49 | const FileInput: React.FunctionComponent = ({
50 | children,
51 | className,
52 | onChange,
53 | id = 'file-input',
54 | showText,
55 | }) => {
56 | const [file, setFile] = useState(undefined);
57 |
58 | const handleChange = useCallback(
59 | (event: React.ChangeEvent) => {
60 | const { files } = event.currentTarget;
61 | const newFile = files[0];
62 |
63 | if (newFile !== file) {
64 | setFile(newFile);
65 | onChange(newFile);
66 | }
67 | },
68 | [onChange],
69 | );
70 |
71 | return (
72 |
73 |
74 | {showText && Choose image}
75 | {children}
76 |
77 | );
78 | };
79 |
80 | export default FileInput;
81 |
--------------------------------------------------------------------------------
/demo/RadioInput.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 |
4 | type Props = React.HTMLAttributes & {
5 | className?: string;
6 | label?: React.ReactNode;
7 | style?: React.CSSProperties;
8 | input: React.InputHTMLAttributes;
9 | };
10 |
11 | const Root = styled.label`
12 | display: inline-flex;
13 | align-items: center;
14 |
15 | input {
16 | margin-right: 5px;
17 | }
18 | `;
19 |
20 | const RadioInput: React.FunctionComponent = ({ label, input, ...rest }) => (
21 |
22 |
23 | {label}
24 |
25 | );
26 |
27 | export default RadioInput;
28 |
--------------------------------------------------------------------------------
/demo/RangeInput.tsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const RangeInput = styled.input.attrs({ type: 'range' })`
4 | width: 300px;
5 | `;
6 |
7 | export default RangeInput;
8 |
--------------------------------------------------------------------------------
/demo/Setting.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 |
4 | const SettingLabel = styled.div`
5 | width: 120px;
6 | white-space: pre;
7 | font-size: 0.9em;
8 | font-family: monospace, monospace;
9 | `;
10 |
11 | const SettingValue = styled.div`
12 | margin-left: 8px;
13 | white-space: pre;
14 | font-size: 0.9em;
15 | font-family: monospace, monospace;
16 | `;
17 |
18 | const SettingRoot = styled.div`
19 | display: flex;
20 |
21 | & + & {
22 | margin-top: 10px;
23 | }
24 | `;
25 |
26 | const Setting = ({
27 | children,
28 | label,
29 | value,
30 | }: {
31 | children: React.ReactNode;
32 | label: string;
33 | value: React.ReactNode;
34 | }) => (
35 |
36 | {label}
37 | {children}
38 | {value}
39 |
40 | );
41 |
42 | export default Setting;
43 |
--------------------------------------------------------------------------------
/demo/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import Demo from './Demo';
4 |
5 | const rootElement = document.createElement('div');
6 |
7 | if (!document.querySelector('div')) {
8 | document.body.appendChild(rootElement);
9 | }
10 |
11 | ReactDOM.render(, rootElement);
12 |
--------------------------------------------------------------------------------
/docs/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | react-blurhash demo
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-blurhash",
3 | "version": "0.3.0",
4 | "description": "Blurhash implementation for React",
5 | "keywords": ["blurhash", "blur", "hash", "image", "react", "component"],
6 | "license": "MIT",
7 | "author": "nygardk",
8 | "repository": {
9 | "type": "git",
10 | "url": "https://github.com/woltapp/react-blurhash"
11 | },
12 | "homepage": "https://blurha.sh",
13 | "main": "dist/index.js",
14 | "types": "dist/index.d.ts",
15 | "module": "dist/esm/index.js",
16 | "scripts": {
17 | "prepublishOnly": "npm run build",
18 | "build": "tsup",
19 | "build:demo": "rm -rf ./docs && webpack -p --config webpack.demo.config.js",
20 | "demo": "webpack-dev-server --config webpack.demo.config.js --hot --progress",
21 | "prettier": "prettier src/**/*.ts",
22 | "prettier-fix": "npm run prettier -- --write",
23 | "ts": "tsc",
24 | "ts:watch": "npm run ts -- --noEmit --watch"
25 | },
26 | "peerDependencies": {
27 | "blurhash": "^2.0.3",
28 | "react": ">=15"
29 | },
30 | "devDependencies": {
31 | "@types/prop-types": "^15.7.3",
32 | "@types/react": "^16.9.53",
33 | "@types/react-dom": "^16.9.8",
34 | "@types/styled-components": "^5.1.4",
35 | "blurhash": "^2.0.3",
36 | "html-webpack-plugin": "^4.5.0",
37 | "prettier": "2.1.2",
38 | "react": "^16.8.6",
39 | "react-dom": "^16.8.6",
40 | "react-hot-loader": "^4.13.0",
41 | "styled-components": "^5.2.0",
42 | "ts-loader": "8.0.6",
43 | "tsup": "^6.5.0",
44 | "typescript": "4.0.3",
45 | "webpack": "4.44.2",
46 | "webpack-cli": "3.3.12",
47 | "webpack-dev-server": "3.11.0"
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/Blurhash.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import BlurhashCanvas from './BlurhashCanvas';
4 |
5 | type Props = React.HTMLAttributes & {
6 | hash: string;
7 | /** CSS height, default: 128 */
8 | height?: number | string | 'auto';
9 | punch?: number;
10 | resolutionX?: number;
11 | resolutionY?: number;
12 | style?: React.CSSProperties;
13 | /** CSS width, default: 128 */
14 | width?: number | string | 'auto';
15 | };
16 |
17 | const canvasStyle: React.CSSProperties = {
18 | position: 'absolute',
19 | top: 0,
20 | bottom: 0,
21 | left: 0,
22 | right: 0,
23 | width: '100%',
24 | height: '100%',
25 | };
26 |
27 | export default class Blurhash extends React.PureComponent {
28 | static defaultProps = {
29 | height: 128,
30 | width: 128,
31 | resolutionX: 32,
32 | resolutionY: 32,
33 | };
34 |
35 | componentDidUpdate() {
36 | if (this.props.resolutionX <= 0) {
37 | throw new Error('resolutionX must be larger than zero');
38 | }
39 |
40 | if (this.props.resolutionY <= 0) {
41 | throw new Error('resolutionY must be larger than zero');
42 | }
43 | }
44 |
45 | render() {
46 | const { hash, height, width, punch, resolutionX, resolutionY, style, ...rest } = this.props;
47 |
48 | return (
49 |
53 |
60 |
61 | );
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/src/BlurhashCanvas.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { decode } from 'blurhash';
3 |
4 | export type Props = React.CanvasHTMLAttributes & {
5 | hash: string;
6 | height?: number;
7 | punch?: number;
8 | width?: number;
9 | };
10 |
11 | export default class BlurhashCanvas extends React.PureComponent {
12 | static defaultProps = {
13 | height: 128,
14 | width: 128,
15 | };
16 |
17 | canvas: HTMLCanvasElement = null;
18 |
19 | componentDidUpdate() {
20 | this.draw();
21 | }
22 |
23 | handleRef = (canvas: HTMLCanvasElement) => {
24 | this.canvas = canvas;
25 | this.draw();
26 | };
27 |
28 | draw = () => {
29 | const { hash, height, punch, width } = this.props;
30 |
31 | if (this.canvas) {
32 | const pixels = decode(hash, width, height, punch);
33 |
34 | const ctx = this.canvas.getContext('2d');
35 | const imageData = ctx.createImageData(width, height);
36 | imageData.data.set(pixels);
37 | ctx.putImageData(imageData, 0, 0);
38 | }
39 | };
40 |
41 | render() {
42 | const { hash, height, width, ...rest } = this.props;
43 |
44 | return ;
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export { default as Blurhash } from './Blurhash';
2 | export { default as BlurhashCanvas } from './BlurhashCanvas';
3 |
--------------------------------------------------------------------------------
/tsconfig.demo.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "rootDir": ".",
5 | "module": "commonjs",
6 | "declaration": false,
7 | "allowJs": true,
8 | "esModuleInterop": true
9 | },
10 | "include": ["src", "demo"]
11 | }
12 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "commonjs",
4 | "moduleResolution": "node",
5 | "target": "es6",
6 | "lib": ["esnext", "dom"],
7 | "declaration": true,
8 | "outDir": "./lib",
9 | "sourceMap": true,
10 | "alwaysStrict": true,
11 | "noImplicitAny": true,
12 | "allowSyntheticDefaultImports": true,
13 | "skipLibCheck": true,
14 | "jsx": "react"
15 | },
16 | "include": ["src"]
17 | }
18 |
--------------------------------------------------------------------------------
/tsup.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig, build } from 'tsup';
2 |
3 | export default defineConfig([
4 | {
5 | name: 'main',
6 | entry: ['./src/index.ts'],
7 | format: ['cjs', 'esm'],
8 | legacyOutput: true,
9 | sourcemap: true,
10 | clean: true,
11 | splitting: false,
12 | dts: false,
13 | minify: true,
14 | },
15 | {
16 | name: 'typedefs',
17 | entry: ['./src/index.ts'],
18 | clean: false,
19 | dts: {
20 | only: true,
21 | },
22 | },
23 | ]);
24 |
--------------------------------------------------------------------------------
/webpack.demo.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const HtmlWebpackPlugin = require('html-webpack-plugin');
3 |
4 | module.exports = {
5 | mode: 'development',
6 | entry: './demo',
7 | resolve: {
8 | extensions: ['.ts', '.tsx', '.js', '.jsx'],
9 | },
10 | output: {
11 | path: path.resolve(__dirname, 'docs'),
12 | filename: 'demo.[hash].js',
13 | },
14 | devtool: 'inline-source-map',
15 | module: {
16 | rules: [
17 | {
18 | test: /\.(jsx?|tsx?)$/,
19 | use: [
20 | 'react-hot-loader/webpack',
21 | {
22 | loader: 'ts-loader',
23 | options: {
24 | configFile: 'tsconfig.demo.json',
25 | },
26 | },
27 | ],
28 | exclude: /node_modules/,
29 | },
30 | ],
31 | },
32 | plugins: [
33 | new HtmlWebpackPlugin({
34 | hash: false,
35 | minify: false,
36 | filename: 'index.html',
37 | title: 'react-blurhash demo',
38 | }),
39 | ],
40 | devServer: {
41 | contentBase: path.join(__dirname, 'demo'),
42 | port: 9000,
43 | },
44 | };
45 |
--------------------------------------------------------------------------------