├── .gitignore ├── tsconfig.json ├── package.json ├── src ├── events.ts ├── index.ts ├── scratcher.ts └── renderer.ts └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | node_modules 3 | 4 | # build directories 5 | dist 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "moduleResolution": "nodenext", 5 | "lib": ["dom", "ESNext"], 6 | "declaration": true, 7 | "strict": true, 8 | "isolatedModules": true, 9 | "skipLibCheck": true 10 | }, 11 | "include": ["src"] 12 | } 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "scratchable", 3 | "version": "0.0.4", 4 | "description": "A scratch card renderer using HTML Canvas", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "files": [ 8 | "dist" 9 | ], 10 | "exports": { 11 | ".": { 12 | "require": { 13 | "types": "./dist/index.d.ts", 14 | "default": "./dist/index.js" 15 | }, 16 | "import": { 17 | "types": "./dist/index.d.mts", 18 | "default": "./dist/index.mjs" 19 | } 20 | } 21 | }, 22 | "scripts": { 23 | "build": "tsup src/index.ts --format cjs,esm --dts --minify" 24 | }, 25 | "repository": { 26 | "type": "git", 27 | "url": "git+https://github.com/HoseungJang/scratchable.git" 28 | }, 29 | "author": "HoseungJang", 30 | "license": "MIT", 31 | "bugs": { 32 | "url": "https://github.com/HoseungJang/scratchable/issues" 33 | }, 34 | "homepage": "https://github.com/HoseungJang/scratchable#readme", 35 | "devDependencies": { 36 | "tsup": "^7.1.0", 37 | "typescript": "^5.1.6" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/events.ts: -------------------------------------------------------------------------------- 1 | type Handler = (e: E) => void; 2 | type RegisteredHandlersByEvent = Map[]>; 3 | 4 | export class Events { 5 | private readonly registeredHandlersByEvent: RegisteredHandlersByEvent = new Map(); 6 | private readonly createEvent: () => E; 7 | 8 | constructor(createEvent: () => E) { 9 | this.createEvent = createEvent; 10 | } 11 | 12 | public on(event: N, handler: Handler) { 13 | const handlers = this.registeredHandlersByEvent.get(event) ?? []; 14 | handlers.push(handler); 15 | this.registeredHandlersByEvent.set(event, handlers); 16 | } 17 | 18 | public off(event: N, handler: Handler) { 19 | const handlers = this.registeredHandlersByEvent.get(event) ?? []; 20 | this.registeredHandlersByEvent.set( 21 | event, 22 | handlers.filter((h) => h !== handler) 23 | ); 24 | } 25 | 26 | public emit(event: N) { 27 | const handlers = this.registeredHandlersByEvent.get(event) ?? []; 28 | handlers.forEach((handler) => handler(this.createEvent())); 29 | } 30 | 31 | public purge(event?: N) { 32 | if (event != null) { 33 | this.registeredHandlersByEvent.delete(event); 34 | } else { 35 | this.registeredHandlersByEvent.clear(); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | MouseScratcher, 3 | Scratcher, 4 | ScratcherEventName, 5 | ScratcherEvent, 6 | ScratcherEventHandler, 7 | ScratcherOptions, 8 | TouchScratcher, 9 | } from "./scratcher"; 10 | import { RendererOptions } from "./renderer"; 11 | 12 | export type ScratchableEventName = ScratcherEventName; 13 | 14 | export type ScratchableEvent = ScratcherEvent; 15 | 16 | export type ScratchableEventHandler = ScratcherEventHandler; 17 | 18 | export type ScratchableOptions = ScratcherOptions & RendererOptions; 19 | 20 | export class Scratchable { 21 | private readonly scratcher: Scratcher; 22 | 23 | constructor(options: ScratchableOptions) { 24 | this.scratcher = this.isTouchDevice ? new TouchScratcher(options) : new MouseScratcher(options); 25 | } 26 | 27 | private get isTouchDevice() { 28 | return "ontouchstart" in window || navigator.maxTouchPoints > 0; 29 | } 30 | 31 | public async render() { 32 | await this.scratcher.render(); 33 | } 34 | 35 | public destroy() { 36 | this.scratcher.destroy(); 37 | } 38 | 39 | public addEventListener(event: ScratcherEventName, handler: ScratcherEventHandler) { 40 | this.scratcher.events.on(event, handler); 41 | } 42 | 43 | public removeEventListener(event: ScratcherEventName, handler: ScratcherEventHandler) { 44 | this.scratcher.events.off(event, handler); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/scratcher.ts: -------------------------------------------------------------------------------- 1 | import { Events } from "./events"; 2 | import { Renderer, RendererOptions } from "./renderer"; 3 | 4 | export type ScratcherEventName = "scratch"; 5 | 6 | export interface ScratcherEvent { 7 | percentage: number; 8 | } 9 | 10 | export type ScratcherEventHandler = (e: ScratcherEvent) => void; 11 | 12 | export interface ScratcherOptions extends RendererOptions { 13 | container: HTMLElement; 14 | radius?: number; 15 | onScratch?: ScratcherEventHandler; 16 | } 17 | 18 | export class Scratcher { 19 | protected readonly container: HTMLElement; 20 | private readonly renderer: Renderer; 21 | private readonly radius: number; 22 | public readonly events: Events; 23 | 24 | private prevScratchPosition: { x: number; y: number } | null = null; 25 | 26 | constructor(options: ScratcherOptions) { 27 | this.container = options.container; 28 | this.renderer = new Renderer(options); 29 | this.radius = options.radius ?? 50; 30 | this.events = new Events(() => ({ percentage: this.renderer.percentage })); 31 | 32 | if (options.onScratch != null) { 33 | this.events.on("scratch", options.onScratch); 34 | } 35 | } 36 | 37 | protected move(x: number, y: number) { 38 | this.renderer.circle(x, y, this.radius); 39 | 40 | if (this.prevScratchPosition != null) { 41 | this.renderer.line(this.prevScratchPosition, { x, y }, this.radius * 2); 42 | } 43 | 44 | this.prevScratchPosition = { x, y }; 45 | this.events.emit("scratch"); 46 | } 47 | 48 | protected end() { 49 | this.prevScratchPosition = null; 50 | } 51 | 52 | public async render() { 53 | await this.renderer.render(); 54 | } 55 | 56 | public destroy() { 57 | this.renderer.destroy(); 58 | this.events.purge(); 59 | } 60 | } 61 | 62 | export class TouchScratcher extends Scratcher { 63 | constructor(options: ScratcherOptions) { 64 | super(options); 65 | this.container.style.touchAction = "none"; 66 | } 67 | 68 | private touchmove = (e: TouchEvent) => { 69 | const { left, top } = this.container.getBoundingClientRect(); 70 | const { clientX, clientY } = e.changedTouches[0]; 71 | 72 | const x = clientX - left; 73 | const y = clientY - top; 74 | this.move(x, y); 75 | }; 76 | 77 | private touchend = () => { 78 | this.end(); 79 | }; 80 | 81 | public async render() { 82 | this.container.addEventListener("touchmove", this.touchmove); 83 | this.container.addEventListener("touchend", this.touchend); 84 | await super.render(); 85 | } 86 | 87 | public destory() { 88 | this.container.removeEventListener("touchmove", this.touchmove); 89 | this.container.removeEventListener("touchend", this.touchend); 90 | super.destroy(); 91 | } 92 | } 93 | 94 | export class MouseScratcher extends Scratcher { 95 | constructor(options: ScratcherOptions) { 96 | super(options); 97 | } 98 | 99 | private mousemove = (e: MouseEvent) => { 100 | if (e.buttons === 0) { 101 | this.end(); 102 | return; 103 | } 104 | 105 | const { left, top } = this.container.getBoundingClientRect(); 106 | const { clientX, clientY } = e; 107 | 108 | const x = clientX - left; 109 | const y = clientY - top; 110 | this.move(x, y); 111 | }; 112 | 113 | public async render() { 114 | this.container.addEventListener("mousemove", this.mousemove); 115 | await super.render(); 116 | } 117 | 118 | public destroy() { 119 | this.container.removeEventListener("mousemove", this.mousemove); 120 | super.destroy(); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/renderer.ts: -------------------------------------------------------------------------------- 1 | export interface RendererOptions { 2 | container: HTMLElement; 3 | background: 4 | | { type: "single"; color: string } 5 | | { 6 | type: "linear-gradient"; 7 | gradients: { offset: number; color: string }[]; 8 | } 9 | | { type: "image"; url: string }; 10 | } 11 | 12 | export class Renderer { 13 | private readonly container: HTMLElement; 14 | private readonly canvas: HTMLCanvasElement; 15 | private readonly ctx: CanvasRenderingContext2D; 16 | private readonly background: RendererOptions["background"]; 17 | 18 | constructor(options: RendererOptions) { 19 | this.container = options.container; 20 | this.canvas = document.createElement("canvas"); 21 | this.ctx = this.canvas.getContext("2d")!; 22 | this.background = options.background; 23 | } 24 | 25 | public get dpr() { 26 | return window.devicePixelRatio; 27 | } 28 | 29 | public get percentage() { 30 | const { data } = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height); 31 | const stride = 32; 32 | const totalPixels = data.length / stride; 33 | let scratchedPixels = 0; 34 | 35 | for (let i = 0; i < data.length; i += stride) { 36 | if (data[i] === 0) { 37 | scratchedPixels++; 38 | } 39 | } 40 | return scratchedPixels / totalPixels; 41 | } 42 | 43 | public async render() { 44 | this.container.style.position = "relative"; 45 | 46 | const dpr = window.devicePixelRatio; 47 | const canvasWidth = this.container.offsetWidth * dpr; 48 | const canvasHeight = this.container.offsetHeight * dpr; 49 | 50 | this.canvas.width = canvasWidth; 51 | this.canvas.height = canvasHeight; 52 | 53 | this.canvas.style.width = "100%"; 54 | this.canvas.style.height = "100%"; 55 | this.canvas.style.position = "absolute"; 56 | this.canvas.style.top = "0"; 57 | this.canvas.style.left = "0"; 58 | this.canvas.style.right = "0"; 59 | this.canvas.style.bottom = "0"; 60 | 61 | return new Promise((resolve) => { 62 | const renderCanvas = () => { 63 | this.ctx.globalCompositeOperation = "destination-out"; 64 | this.container.appendChild(this.canvas); 65 | resolve(); 66 | }; 67 | 68 | switch (this.background.type) { 69 | case "single": { 70 | this.ctx.fillStyle = this.background.color; 71 | this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); 72 | renderCanvas(); 73 | return; 74 | } 75 | case "linear-gradient": { 76 | const grd = this.ctx.createLinearGradient(0, 0, this.canvas.width, this.canvas.height); 77 | 78 | this.background.gradients.forEach((gradient) => { 79 | grd.addColorStop(gradient.offset, gradient.color); 80 | }); 81 | 82 | this.ctx.fillStyle = grd; 83 | this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); 84 | renderCanvas(); 85 | return; 86 | } 87 | case "image": { 88 | const image = new Image(); 89 | 90 | image.onload = () => { 91 | this.ctx.drawImage(image, 0, 0, this.canvas.width, this.canvas.height); 92 | renderCanvas(); 93 | }; 94 | 95 | image.src = this.background.url; 96 | return; 97 | } 98 | } 99 | }); 100 | } 101 | 102 | public circle(x: number, y: number, radius: number) { 103 | const dpr = this.dpr; 104 | 105 | this.ctx.beginPath(); 106 | this.ctx.arc(x * dpr, y * dpr, radius * dpr, 0, 2 * Math.PI); 107 | this.ctx.fill(); 108 | } 109 | 110 | public line(from: { x: number; y: number }, to: { x: number; y: number }, width: number) { 111 | const dpr = this.dpr; 112 | 113 | this.ctx.beginPath(); 114 | this.ctx.moveTo(from.x * dpr, from.y * dpr); 115 | this.ctx.lineTo(to.x * dpr, to.y * dpr); 116 | this.ctx.lineWidth = width * dpr; 117 | this.ctx.stroke(); 118 | } 119 | 120 | public destroy() { 121 | this.container.removeChild(this.canvas); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # scratchable 2 | 3 | https://github.com/HoseungJang/scratchable/assets/39669819/5e3e5e4b-ce97-47b5-877c-d04d8375e843 4 | 5 | # Overview 6 | 7 | `scratchable` is a scratch card renderer using HTML Canvas. It renders a scratchable canvas element on your content, and provides percentage of scratched pixels. 8 | 9 | # Usage 10 | 11 | First of all, you should pass `container` to `Scratchable`, which will be overlapped by a scratchable canvas. 12 | 13 | ```javascript 14 | const container = document.getElementById("container"); 15 | 16 | const scratchable = new Scratchable({ 17 | container, 18 | /* ... */ 19 | }); 20 | ``` 21 | 22 | ```html 23 |
/* CONTENT */
24 | ``` 25 | 26 | Or in React, 27 | 28 | ```typescript 29 | const container = ref.current; 30 | 31 | const scratchable = new Scratchable({ 32 | container, 33 | /* ... */ 34 | }); 35 | ``` 36 | 37 | ```tsx 38 |
{/* CONTENT */}
39 | ``` 40 | 41 | And then it will render the canvas on your `/* CONTENT */`, when you call `Scratchable.render()`. It returns `Promise`. 42 | 43 | ```typescript 44 | await scratchable.render(); 45 | ``` 46 | 47 | And you can also remove the rendered canvas. 48 | 49 | ```typescript 50 | scratchable.destroy(); 51 | ``` 52 | 53 | This is the basic. Now let's see another required option `background`. 54 | 55 | ## Background 56 | 57 | You should pass `background` to `Scratchable`, which is the color of the rendered canvas. 58 | 59 | It has three kinds of type, `single`, `linear-gradient` and `image`. 60 | 61 | ### Single Background 62 | 63 | ```typescript 64 | new Scratchable({ 65 | /* ... */ 66 | background: { 67 | type: "single", 68 | color: "#FA58D0", 69 | }, 70 | }); 71 | ``` 72 | 73 | https://github.com/HoseungJang/scratchable/assets/39669819/7915c2af-8bbe-43d8-9169-631fd7124b91 74 | 75 | ### Linear Gradient Background 76 | 77 | ```typescript 78 | new Scratchable({ 79 | /* ... */ 80 | background: { 81 | type: "linear-gradient", 82 | gradients: [ 83 | { offset: 0, color: "#FA58D0" }, 84 | { offset: 0.5, color: "#DA81F5" }, 85 | { offset: 1, color: "#BE81F7" }, 86 | ], 87 | }, 88 | }); 89 | ``` 90 | 91 | https://github.com/HoseungJang/scratchable/assets/39669819/bef24ef0-2860-4150-9133-35a922950936 92 | 93 | ### Image Background 94 | 95 | ```typescript 96 | new Scratchable({ 97 | /* ... */ 98 | background: { 99 | type: "image", 100 | url: "karina.jpeg", 101 | }, 102 | }); 103 | ``` 104 | 105 | https://github.com/HoseungJang/scratchable/assets/39669819/8fd80f49-bb3c-4582-af82-b57273e6a8c2 106 | 107 | ## Radius 108 | 109 | You can set radius of a circle which is rendered when you scratch the canvas. Let's compare between 20 and 40. 110 | 111 | ### 20 112 | 113 | ```typescript 114 | new Scratchable({ 115 | /* ... */ 116 | radius: 20, 117 | }); 118 | ``` 119 | 120 | https://github.com/HoseungJang/scratchable/assets/39669819/44c38fac-a130-427d-8c8e-874f03e132f1 121 | 122 | ### 40 123 | 124 | ```typescript 125 | new Scratchable({ 126 | /* ... */ 127 | radius: 40, 128 | }); 129 | ``` 130 | 131 | https://github.com/HoseungJang/scratchable/assets/39669819/b8421e3b-f79e-4114-a002-0f8c066e1c95 132 | 133 | ## Scratch Event 134 | 135 | You can register a function which will be called when `scratch` event fires. The event fires when an user is scratching the canvas. 136 | 137 | ```typescript 138 | const handler = (e: ScratchableEvent) => { 139 | /* ... */ 140 | }; 141 | 142 | scratchable.addEventListener("scratch", handler); 143 | scratchable.removeEventListener("scratch", handler); 144 | ``` 145 | 146 | ## Scratched Percentage 147 | 148 | You can get percentage(0 ~ 1) from `ScratchEvent` above. The percentage is ratio of scratched area to all scratchable area. 149 | 150 | ```typescript 151 | const handler = (e: ScratchableEvent) => { 152 | if (e.percentage > 0.5) { 153 | scratchable.destroy(); 154 | } 155 | }; 156 | 157 | scratchable.addEventListener("scratch", handler); 158 | ``` 159 | 160 | https://github.com/HoseungJang/scratchable/assets/39669819/877e7224-5311-443e-84d1-be24632f5d21 161 | --------------------------------------------------------------------------------