├── .editorconfig ├── .gitignore ├── .npmignore ├── .prettierrc ├── CHANGELOG.md ├── README.md ├── demo ├── BlurhashImageEncoder.tsx ├── Button.tsx ├── Demo.tsx ├── FileInput.tsx ├── RadioInput.tsx ├── RangeInput.tsx ├── Setting.tsx └── index.tsx ├── docs ├── demo.f335462148ad584661d8.js └── index.html ├── package.json ├── src ├── Blurhash.tsx ├── BlurhashCanvas.tsx └── index.ts ├── tsconfig.demo.json ├── tsconfig.json ├── tsup.config.ts ├── webpack.demo.config.js └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig: http://EditorConfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | indent_style = space 11 | tab_width = 2 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | lib/ 3 | es/ 4 | dist 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | tsconfig.json 2 | src 3 | demo 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "overrides": [ 4 | { 5 | "files": ["*.js", "*.jsx", "*.ts", "*.tsx"], 6 | "options": { 7 | "singleQuote": true, 8 | "trailingComma": "all" 9 | } 10 | }, 11 | { 12 | "files": "*.json", 13 | "options": { 14 | "parser": "json" 15 | } 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.3.0 4 | 5 | - Use `tsup` for bundling 6 | 7 | ## 0.2.0 8 | 9 | - Blurhash v2 10 | - Drop IE11 support 11 | 12 | ## 0.1.1 (July 1, 2019) 13 | 14 | - `Blurhash` component's `width` and `height` can now also be a string (CSS property) 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-blurhash 2 | 3 | [![NPM Version](https://img.shields.io/npm/v/react-blurhash.svg?style=flat)](https://www.npmjs.com/package/react-blurhash) 4 | [![NPM Downloads](https://img.shields.io/npm/dm/react-blurhash.svg?style=flat)](https://npmcharts.com/compare/react-blurhash?minimal=true) 5 | 6 | > React components for using the [blurhash algorithm](https://blurha.sh) in your React projects 7 | 8 | [Demo](https://woltapp.github.io/react-blurhash/) 9 | 10 | ## Install 11 | 12 | ```sh 13 | npm install --save blurhash react-blurhash 14 | ``` 15 | 16 | ## Usage 17 | 18 | ### `` 19 | 20 | ```js 21 | import { Blurhash } from "react-blurhash"; 22 | ``` 23 | 24 | ### Description 25 | 26 | `Blurhash` component is the recommended way to render blurhashes in your React projects. 27 | It uses `BlurhashCanvas` and a wrapping `div` to scale the decoded image to your desired size. You may control the quality of the decoded image with `resolutionX` and `resolutionY` props. 28 | 29 | #### Props 30 | 31 | | name | description | 32 | | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 33 | | `hash` (string) | The encoded blurhash string. | 34 | | `width` (int \| string) | Width (CSS) of the decoded image. | 35 | | `height` (int \| string) | Height (CSS) of the decoded image. | 36 | | `resolutionX` (int) | The X-axis resolution in which the decoded image will be rendered at. Recommended min. 32px. Large sizes (>128px) will greatly decrease rendering performance. (Default: 32) | 37 | | `resolutionY` (int) | The Y-axis resolution in which the decoded image will be rendered at. Recommended min. 32px. Large sizes (>128px) will greatly decrease rendering performance. (Default: 32) | 38 | | `punch` (int) | Controls the "punch" value (~contrast) of the blurhash decoding algorithm. (Default: 1) | 39 | 40 | #### Example 41 | 42 | ```jsx 43 | 51 | ``` 52 | 53 | ### `` 54 | 55 | ```js 56 | import { BlurhashCanvas } from "react-blurhash"; 57 | ``` 58 | 59 | ### Description 60 | 61 | `BlurhashCanvas` is the barebones implementation of a blurhash string to a canvas. You may want to use it instead of the `Blurhash` component e.g. if you want to control the scaling yourself. 62 | 63 | #### Props 64 | 65 | | name | description | 66 | | --------------- | --------------------------------------------------------------------------------------- | 67 | | `hash` (string) | The encoded blurhash string. | 68 | | `width` (int) | Width of the decoded image. | 69 | | `height` (int) | Height of the decoded image. | 70 | | `punch` (int) | Controls the "punch" value (~contrast) of the blurhash decoding algorithm. (Default: 1) | 71 | 72 | #### Example 73 | 74 | ```jsx 75 | 76 | ``` 77 | 78 | ## Browser support 79 | 80 | Blurhash depends on `Uint8ClampedArray`, which is supported on all mainstream browsers and >=IE11. 81 | -------------------------------------------------------------------------------- /demo/BlurhashImageEncoder.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo, useState, useEffect, useCallback } from 'react'; 2 | import { encode } from 'blurhash'; 3 | import styled from 'styled-components'; 4 | 5 | import FileInput from './FileInput'; 6 | import Setting from './Setting'; 7 | import RangeInput from './RangeInput'; 8 | 9 | const Root = styled.div` 10 | border: 1px solid #e4e4e4; 11 | border-radius: 4px; 12 | `; 13 | 14 | const StyledFileInput = styled(FileInput)``; 15 | 16 | const ImagePreviewContainer = styled.div` 17 | position: relative; 18 | display: flex; 19 | width: 100%; 20 | max-width: 1024px; 21 | height: 400px; 22 | overflow: hidden; 23 | `; 24 | 25 | const ImagePreview = styled.img` 26 | width: 100%; 27 | background: #f4f4f4; 28 | object-fit: contain; 29 | object-position: 50% 50%; 30 | `; 31 | 32 | const ImageFileName = styled.span` 33 | position: absolute; 34 | right: 10px; 35 | bottom: 10px; 36 | font-size: 0.75em; 37 | color: white; 38 | padding: 1px 3px; 39 | border-radius: 2px; 40 | background-color: rgba(30, 30, 30, 0.6); 41 | `; 42 | 43 | const Settings = styled.div` 44 | border-top: 1px solid #e4e4e4; 45 | padding: 15px; 46 | `; 47 | 48 | const BlurhashResultContainer = styled.div` 49 | padding: 15px; 50 | border-top: 1px solid #e4e4e4; 51 | border-bottom-left-radius: 4px; 52 | border-bottom-right-radius: 4px; 53 | `; 54 | 55 | const Heading3 = styled.h3` 56 | font-weight: 600; 57 | font-size: 1em; 58 | color: 777; 59 | margin: 0 0 10px 0; 60 | `; 61 | 62 | const ResultBlurhash = styled.div` 63 | overflow-wrap: break-word; 64 | font-size: 1.05em; 65 | font-family: monospace; 66 | width: 100%; 67 | box-sizing: border-box; 68 | `; 69 | 70 | type Props = { 71 | onChange: (hash: string) => void; 72 | value?: string; 73 | }; 74 | 75 | const getClampedSize = ( 76 | width: number, 77 | height: number, 78 | max: number, 79 | ): { width: number; height: number } => { 80 | if (width >= height && width > max) { 81 | return { width: max, height: Math.round((height / width) * max) }; 82 | } 83 | 84 | if (height > width && height > max) { 85 | return { width: Math.round((width / height) * max), height: max }; 86 | } 87 | 88 | return { width, height }; 89 | }; 90 | 91 | const loadImage = async (src: string): Promise => 92 | new Promise((resolve, reject) => { 93 | const img = new Image(); 94 | img.onload = () => resolve(img); 95 | img.onerror = (...args) => reject(args); 96 | img.src = src; 97 | }); 98 | 99 | const getImageData = (image: HTMLImageElement, resolutionX: number, resolutionY: number) => { 100 | const canvas = document.createElement('canvas'); 101 | canvas.width = resolutionX; 102 | canvas.height = resolutionY; 103 | const context = canvas.getContext('2d'); 104 | context.drawImage(image, 0, 0, resolutionX, resolutionY); 105 | return context.getImageData(0, 0, resolutionX, resolutionY); 106 | }; 107 | 108 | const BlurhashImageEncoder: React.FunctionComponent = ({ 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 | --------------------------------------------------------------------------------