diff --git a/bun.lockb b/bun.lockb index 74697c6..4875a99 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 58d3bff..834bde1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "displacementx", - "version": "0.0.1-alpha.4", + "version": "0.0.1-alpha.5", "scripts": { "dev": "next dev", "build": "next build", @@ -18,7 +18,9 @@ "geist": "1.0.0", "next": "14.0.1", "react": "18.2.0", + "react-colorful": "5.6.1", "react-dom": "18.2.0", + "usehooks-ts": "2.9.1", "zustand": "4.4.6" }, "devDependencies": { diff --git a/src/components/pages/Generator/CanvasSection/CanvasSection.tsx b/src/components/pages/Generator/CanvasSection/CanvasSection.tsx index 618277a..a633b0f 100644 --- a/src/components/pages/Generator/CanvasSection/CanvasSection.tsx +++ b/src/components/pages/Generator/CanvasSection/CanvasSection.tsx @@ -7,11 +7,16 @@ import {SectionTitle} from '../SectionTitle'; import {saveImage} from './utils/saveImage'; import {draw} from './utils/draw'; import {Switch} from '@/components/ui/Switch'; +import {Gradient} from './Gradient'; +import {getCtx2dFromRef} from './utils/getCtx2dFromRef'; import {getCanvasDimensions} from './utils/getCanvasDimensions'; import {clearCanvas} from './utils/clearCanvas'; import {drawNormal} from './utils/drawNormal'; +import {drawColor} from './utils/drawColor'; import {drawInvert} from './utils/drawInvert'; +type PreviewType = 'original' | 'normal' | 'color'; + export function CanvasSection() { const [is8k, setIs8k] = useState(false); const width = is8k ? 8192 : 4096; @@ -19,18 +24,19 @@ export function CanvasSection() { const [isPristine, setIsPristine] = useState(true); const [isRendering, setIsRendering] = useState(false); - const [isNormalPreview, setIsNormalPreview] = useState(false); + const [previewType, setPreviewType] = useState('original'); const [renderTimeMs, setRenderTimeMs] = useState(); const canvasRef = useRef(null); const canvasOriginalPreviewDataUrl = useRef(undefined); + const gradientCanvasRef = useRef(null); const render = () => { setIsPristine(false); setIsRendering(true); - setIsNormalPreview(false); + setPreviewType('original'); - const ctx2d = getCtx2d(canvasRef); + const ctx2d = getCtx2dFromRef(canvasRef); const { iterations, @@ -134,46 +140,56 @@ export function CanvasSection() { }; const onIs8kChange = (is8k: boolean) => { - const ctx2d = getCtx2d(canvasRef); + const ctx2d = getCtx2dFromRef(canvasRef); clearCanvas(ctx2d); setIsPristine(true); - setIsNormalPreview(false); + setPreviewType('original'); setRenderTimeMs(undefined); setIs8k(is8k); }; - const invert = () => { + const quickRender = (callback: () => void) => { const renderTimeStartMs: number = performance.now(); setIsRendering(true); - setIsNormalPreview(false); - - const updateCanvas = () => { - const ctx2d = getCtx2d(canvasRef); - drawInvert(ctx2d); - }; // Put a small timeout to allow the UI to update before canvas takes the main thread over setTimeout(() => { - updateCanvas(); + callback(); setIsRendering(false); setRenderTimeMs(performance.now() - renderTimeStartMs); }, 20); }; - const toggleNormalPreview = () => { - const isNormalPreviewNew = !isNormalPreview; - const renderTimeStartMs: number = performance.now(); - setIsRendering(true); + const invert = () => { + quickRender(() => { + const ctx2d = getCtx2dFromRef(canvasRef); + drawInvert(ctx2d); + }); + }; - const updateCanvas = () => { - const ctx2d = getCtx2d(canvasRef); + const togglePreviewFor = (type: PreviewType) => () => { + quickRender(() => { + const shouldDrawNonOriginal = previewType === 'original'; - if (isNormalPreviewNew) { - // Draw normal preview + const ctx2d = getCtx2dFromRef(canvasRef); + const ctx2dGradient = getCtx2dFromRef(gradientCanvasRef); + + if (shouldDrawNonOriginal) { + // Save original preview canvasOriginalPreviewDataUrl.current = ctx2d.canvas.toDataURL(); - drawNormal(ctx2d); + // Draw preview based on type + switch (type) { + case 'normal': + drawNormal(ctx2d); + break; + case 'color': + drawColor({ctx2d, ctx2dGradient}); + break; + default: + break; + } } else { // Restore original preview const dataUrl = canvasOriginalPreviewDataUrl.current; @@ -188,15 +204,9 @@ export function CanvasSection() { }; } } - }; - // Put a small timeout to allow the UI to update before canvas takes the main thread over - setTimeout(() => { - updateCanvas(); - setIsNormalPreview(isNormalPreviewNew); - setIsRendering(false); - setRenderTimeMs(performance.now() - renderTimeStartMs); - }, 20); + setPreviewType(shouldDrawNonOriginal ? type : 'original'); + }); }; return ( @@ -231,18 +241,35 @@ export function CanvasSection() {
+
+
+ +
); } @@ -281,15 +308,3 @@ function Canvas({canvasRef, width, height, isRendering}: CanvasProps) { ); } - -const getCtx2d = ( - canvasRef: React.RefObject, -): CanvasRenderingContext2D => { - const canvas = canvasRef.current; - if (!canvas) throw new TypeError('Canvas not found in ref'); - - const ctx2d = canvas.getContext('2d'); - if (!ctx2d) throw new TypeError('Error getting 2d context from canvas'); - - return ctx2d; -}; diff --git a/src/components/pages/Generator/CanvasSection/Gradient/ColorPicker/ColorPicker.tsx b/src/components/pages/Generator/CanvasSection/Gradient/ColorPicker/ColorPicker.tsx new file mode 100644 index 0000000..b9bc210 --- /dev/null +++ b/src/components/pages/Generator/CanvasSection/Gradient/ColorPicker/ColorPicker.tsx @@ -0,0 +1,40 @@ +import {useCallback, useRef, useState} from 'react'; +import {useOnClickOutside} from 'usehooks-ts'; +import {rgbToHex} from '@/utils/colors'; +import {type ColorRGB} from '@/types'; +import {RgbColorPicker} from 'react-colorful'; + +type ColorPickerProps = { + readonly color: ColorRGB; + readonly setColor: (newColor: ColorRGB) => void; +}; + +export function ColorPicker({color, setColor}: ColorPickerProps) { + const colorHex = rgbToHex(color); + + const popoverRef = useRef(null); + const [popoverOpen, setPopoverOpen] = useState(false); + const closePopover = useCallback(() => { + setPopoverOpen(false); + }, []); + useOnClickOutside(popoverRef, closePopover); + + return ( +
+
{ + setPopoverOpen(true); + }} + > + {popoverOpen && ( +
+ +
+ )} +
+ {colorHex} +
+ ); +} diff --git a/src/components/pages/Generator/CanvasSection/Gradient/ColorPicker/index.ts b/src/components/pages/Generator/CanvasSection/Gradient/ColorPicker/index.ts new file mode 100644 index 0000000..d99937e --- /dev/null +++ b/src/components/pages/Generator/CanvasSection/Gradient/ColorPicker/index.ts @@ -0,0 +1 @@ +export {ColorPicker} from './ColorPicker'; diff --git a/src/components/pages/Generator/CanvasSection/Gradient/Gradient.tsx b/src/components/pages/Generator/CanvasSection/Gradient/Gradient.tsx new file mode 100644 index 0000000..a44c5af --- /dev/null +++ b/src/components/pages/Generator/CanvasSection/Gradient/Gradient.tsx @@ -0,0 +1,104 @@ +import { + forwardRef, + useEffect, + useImperativeHandle, + useRef, + useState, +} from 'react'; +import {Button} from '@/components/ui/Button'; +import {rgb} from '@/utils/colors'; +import {type ColorRGB} from '@/types'; +import {getCtx2dFromRef} from '../utils/getCtx2dFromRef'; +import {getCanvasDimensions} from '../utils/getCanvasDimensions'; +import {ColorPicker} from './ColorPicker'; + +const colorsMin = 2; +const colorsMax = 20; +const colorsDefault: ColorRGB[] = [ + {r: 0, g: 255, b: 255}, + {r: 149, g: 0, b: 255}, + {r: 255, g: 229, b: 0}, +]; + +export const Gradient = forwardRef((_, forwardedRef) => { + const canvasRef = useRef(null); + useImperativeHandle(forwardedRef, () => { + if (!canvasRef.current) { + throw new TypeError('Canvas ref is not set'); + } + + return canvasRef.current; + }); + + const [colors, setColors] = useState(colorsDefault); + + const addColor = () => { + setColors([...colors, {r: 0, g: 0, b: 0}]); + }; + + const setColorForIndex = (index: number) => (newColor: ColorRGB) => { + const newColors = [...colors]; + newColors[index] = newColor; + setColors(newColors); + }; + + const deleteColorForIndex = (index: number) => () => { + const newColors = [...colors]; + newColors.splice(index, 1); + setColors(newColors); + }; + + useEffect(() => { + const ctx2d = getCtx2dFromRef(canvasRef); + const {w, h} = getCanvasDimensions(ctx2d); + + ctx2d.clearRect(0, 0, w, h); + + const gradient = ctx2d.createLinearGradient(0, 0, w, 0); + for (let i = 0; i < colors.length; i++) { + gradient.addColorStop(i / Math.max(colors.length - 1, 1), rgb(colors[i])); + } + + ctx2d.fillStyle = gradient; + ctx2d.fillRect(0, 0, w, h); + }, [colors]); + + return ( +
+

Gradient

+
+
+ +
+
+
+ {colors.map((color, index) => ( +
+ + +
+ ))} +
+
+ +
+
+
+
+ ); +}); diff --git a/src/components/pages/Generator/CanvasSection/Gradient/index.ts b/src/components/pages/Generator/CanvasSection/Gradient/index.ts new file mode 100644 index 0000000..78b5d40 --- /dev/null +++ b/src/components/pages/Generator/CanvasSection/Gradient/index.ts @@ -0,0 +1 @@ +export {Gradient} from './Gradient'; diff --git a/src/components/pages/Generator/CanvasSection/utils/drawColor.ts b/src/components/pages/Generator/CanvasSection/utils/drawColor.ts new file mode 100644 index 0000000..a99c71c --- /dev/null +++ b/src/components/pages/Generator/CanvasSection/utils/drawColor.ts @@ -0,0 +1,35 @@ +import {getCanvasDimensions} from './getCanvasDimensions'; + +export const drawColor = ({ + ctx2d, + ctx2dGradient, +}: { + ctx2d: CanvasRenderingContext2D; + ctx2dGradient: CanvasRenderingContext2D; +}): void => { + const {w, h} = getCanvasDimensions(ctx2d); + const {w: wGradient} = getCanvasDimensions(ctx2dGradient); + + const source = ctx2d.getImageData(0, 0, w, h); + const sourceGradient = ctx2dGradient.getImageData(0, 0, wGradient, 1); + const destination = ctx2d.createImageData(w, h); + + const paletteR: number[] = []; + const paletteG: number[] = []; + const paletteB: number[] = []; + + for (let i = 0; i < wGradient * 4; i += 4) { + paletteR.push(sourceGradient.data[i]); + paletteG.push(sourceGradient.data[i + 1]); + paletteB.push(sourceGradient.data[i + 2]); + } + + for (let i = 0; i < source.data.length; i += 4) { + destination.data[i] = paletteR[source.data[i]]; + destination.data[i + 1] = paletteG[source.data[i + 1]]; + destination.data[i + 2] = paletteB[source.data[i + 2]]; + destination.data[i + 3] = source.data[i + 3]; + } + + ctx2d.putImageData(destination, 0, 0); +}; diff --git a/src/components/pages/Generator/CanvasSection/utils/getCtx2dFromRef.ts b/src/components/pages/Generator/CanvasSection/utils/getCtx2dFromRef.ts new file mode 100644 index 0000000..eeaae63 --- /dev/null +++ b/src/components/pages/Generator/CanvasSection/utils/getCtx2dFromRef.ts @@ -0,0 +1,11 @@ +export const getCtx2dFromRef = ( + canvasRef: React.RefObject, +): CanvasRenderingContext2D => { + const canvas = canvasRef.current; + if (!canvas) throw new TypeError('Canvas not found in ref'); + + const ctx2d = canvas.getContext('2d'); + if (!ctx2d) throw new TypeError('Error getting 2d context from canvas'); + + return ctx2d; +}; diff --git a/src/types/index.ts b/src/types/index.ts index 5c7d519..2f6eeb5 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1 +1,16 @@ export type NumberDual = [number, number]; + +// eslint-disable-next-line @typescript-eslint/naming-convention +export type ColorRGB = { + r: number; + g: number; + b: number; +}; + +// eslint-disable-next-line @typescript-eslint/naming-convention +export type ColorRGBA = { + r: number; + g: number; + b: number; + a: number; +}; diff --git a/src/utils/colors.test.ts b/src/utils/colors.test.ts index ffd1573..849919a 100644 --- a/src/utils/colors.test.ts +++ b/src/utils/colors.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it} from 'vitest'; -import {rgb, rgba, xxx, xxxa} from './colors'; +import {rgb, rgba, rgbToHex, xxx, xxxa} from './colors'; describe('colors', () => { describe('rgb', () => { @@ -18,6 +18,16 @@ describe('colors', () => { }); }); + describe('rgbToHex', () => { + it('works', () => { + expect(rgbToHex({r: 0, g: 0, b: 0})).toBe('#000000'); + expect(rgbToHex({r: 12, g: 0, b: 0})).toBe('#0c0000'); + expect(rgbToHex({r: 16, g: 132, b: 0})).toBe('#108400'); + expect(rgbToHex({r: 0, g: 192, b: 255})).toBe('#00c0ff'); + expect(rgbToHex({r: 255, g: 254, b: 10})).toBe('#fffe0a'); + }); + }); + describe('xxx', () => { it('works', () => { expect(xxx({x: 0})).toBe('rgb(0,0,0)'); diff --git a/src/utils/colors.ts b/src/utils/colors.ts index f1b2322..0ed027b 100644 --- a/src/utils/colors.ts +++ b/src/utils/colors.ts @@ -1,31 +1,16 @@ -/** - * Converts RGB (0 - 255) numbers to string color in `rgb(r,g,b)` format. - */ -export const rgb = ({r, g, b}: {r: number; g: number; b: number}): string => - `rgb(${r},${g},${b})`; +import {type ColorRGB, type ColorRGBA} from '@/types'; -/** - * Converts RGB (0 - 255) + A (0 - 100) numbers to string color in `rgb(r,g,b,a)` format. - */ -export const rgba = ({ - r, - g, - b, - a, -}: { - r: number; - g: number; - b: number; - a: number; -}): string => `rgb(${r},${g},${b},${a / 100})`; +export const rgb = ({r, g, b}: ColorRGB): string => `rgb(${r},${g},${b})`; + +export const rgba = ({r, g, b, a}: ColorRGBA): string => + `rgb(${r},${g},${b},${a / 100})`; + +export const rgbToHex = ({r, g, b}: ColorRGB): string => { + const toHex = (x: number): string => x.toString(16).padStart(2, '0'); + return `#${toHex(r)}${toHex(g)}${toHex(b)}`; +}; -/** - * Converts X (0 - 255) number to grayscale string color in `rgb(x,x,x)` format. - */ export const xxx = ({x}: {x: number}): string => rgb({r: x, g: x, b: x}); -/** - * Converts X (0 - 255) + A (0 - 100) numbers to grayscale string color in `rgb(x,x,x,a)` format. - */ export const xxxa = ({x, a}: {x: number; a: number}): string => rgba({r: x, g: x, b: x, a});