feat(gui): show mask brush preview (fixes #39)
This commit is contained in:
parent
e915ab5b8d
commit
a87dc451fd
|
@ -2,7 +2,7 @@ import { doesExist, Maybe, mustExist } from '@apextoaster/js-utils';
|
|||
import { FormatColorFill, Gradient } from '@mui/icons-material';
|
||||
import { Button, Stack } from '@mui/material';
|
||||
import { throttle } from 'lodash';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { RefObject, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { ConfigParams, DEFAULT_BRUSH, SAVE_TIME } from '../config.js';
|
||||
import { NumericField } from './NumericField';
|
||||
|
@ -28,30 +28,6 @@ export const MASK_STATE = {
|
|||
dirty: 'dirty',
|
||||
};
|
||||
|
||||
export function floodBelow(n: number): number {
|
||||
if (n < THRESHOLDS.upper) {
|
||||
return COLORS.black;
|
||||
} else {
|
||||
return COLORS.white;
|
||||
}
|
||||
}
|
||||
|
||||
export function floodAbove(n: number): number {
|
||||
if (n > THRESHOLDS.lower) {
|
||||
return COLORS.white;
|
||||
} else {
|
||||
return COLORS.black;
|
||||
}
|
||||
}
|
||||
|
||||
export function floodGray(n: number): number {
|
||||
return n;
|
||||
}
|
||||
|
||||
export function grayToRGB(n: number): string {
|
||||
return `rgb(${n.toFixed(0)},${n.toFixed(0)},${n.toFixed(0)})`;
|
||||
}
|
||||
|
||||
export type FloodFn = (n: number) => number;
|
||||
|
||||
export interface Point {
|
||||
|
@ -71,28 +47,6 @@ export interface MaskCanvasProps {
|
|||
export function MaskCanvas(props: MaskCanvasProps) {
|
||||
const { base, config, source } = props;
|
||||
|
||||
function floodMask(flood: FloodFn) {
|
||||
const buffer = mustExist(bufferRef.current);
|
||||
const ctx = mustExist(buffer.getContext('2d'));
|
||||
const image = ctx.getImageData(0, 0, buffer.width, buffer.height);
|
||||
const pixels = image.data;
|
||||
|
||||
for (let x = 0; x < buffer.width; ++x) {
|
||||
for (let y = 0; y < buffer.height; ++y) {
|
||||
const i = (y * buffer.width * PIXEL_SIZE) + (x * PIXEL_SIZE);
|
||||
const hue = (pixels[i] + pixels[i + 1] + pixels[i + 2]) / PIXEL_WEIGHT;
|
||||
const final = flood(hue);
|
||||
|
||||
pixels[i] = final;
|
||||
pixels[i + 1] = final;
|
||||
pixels[i + 2] = final;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.putImageData(image, 0, 0);
|
||||
save();
|
||||
}
|
||||
|
||||
function saveMask(): void {
|
||||
if (doesExist(bufferRef.current)) {
|
||||
if (maskState.current === MASK_STATE.clean) {
|
||||
|
@ -107,27 +61,21 @@ export function MaskCanvas(props: MaskCanvasProps) {
|
|||
}
|
||||
|
||||
function drawBuffer() {
|
||||
if (doesExist(bufferRef.current) && doesExist(canvasRef.current)) {
|
||||
const dest = mustExist(canvasRef.current);
|
||||
const ctx = mustExist(dest.getContext('2d'));
|
||||
|
||||
ctx.clearRect(0, 0, dest.width, dest.height);
|
||||
if (doesExist(brushRef.current) && doesExist(bufferRef.current) && doesExist(canvasRef.current)) {
|
||||
const { ctx } = getClearContext(canvasRef);
|
||||
ctx.globalAlpha = MASK_OPACITY;
|
||||
ctx.drawImage(bufferRef.current, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
function drawCircle(ctx: CanvasRenderingContext2D, point: Point): void {
|
||||
ctx.beginPath();
|
||||
ctx.arc(point.x, point.y, brushSize, 0, FULL_CIRCLE);
|
||||
ctx.fill();
|
||||
if (maskState.current !== MASK_STATE.painting) {
|
||||
ctx.drawImage(brushRef.current, 0, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawSource(file: Blob): void {
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
const buffer = mustExist(bufferRef.current);
|
||||
const ctx = mustExist(buffer.getContext('2d'));
|
||||
const { ctx } = getContext(bufferRef);
|
||||
ctx.drawImage(image, 0, 0);
|
||||
URL.revokeObjectURL(src);
|
||||
|
||||
|
@ -139,6 +87,11 @@ export function MaskCanvas(props: MaskCanvasProps) {
|
|||
}
|
||||
|
||||
function finishPainting() {
|
||||
if (doesExist(brushRef.current)) {
|
||||
getClearContext(brushRef);
|
||||
drawBuffer();
|
||||
}
|
||||
|
||||
if (maskState.current === MASK_STATE.painting) {
|
||||
maskState.current = MASK_STATE.dirty;
|
||||
save();
|
||||
|
@ -147,6 +100,8 @@ export function MaskCanvas(props: MaskCanvasProps) {
|
|||
|
||||
const save = useMemo(() => throttle(saveMask, SAVE_TIME), []);
|
||||
|
||||
// eslint-disable-next-line no-null/no-null
|
||||
const brushRef = useRef<HTMLCanvasElement>(null);
|
||||
// eslint-disable-next-line no-null/no-null
|
||||
const bufferRef = useRef<HTMLCanvasElement>(null);
|
||||
// eslint-disable-next-line no-null/no-null
|
||||
|
@ -162,11 +117,11 @@ export function MaskCanvas(props: MaskCanvasProps) {
|
|||
useEffect(() => {
|
||||
// including clicks.length prevents the initial render from saving a blank canvas
|
||||
if (doesExist(bufferRef.current) && maskState.current === MASK_STATE.painting && clicks.length > 0) {
|
||||
const ctx = mustExist(bufferRef.current.getContext('2d'));
|
||||
const { ctx } = getContext(bufferRef);
|
||||
ctx.fillStyle = grayToRGB(brushColor);
|
||||
|
||||
for (const click of clicks) {
|
||||
drawCircle(ctx, click);
|
||||
drawCircle(ctx, click, brushSize);
|
||||
}
|
||||
|
||||
clicks.length = 0;
|
||||
|
@ -206,6 +161,14 @@ export function MaskCanvas(props: MaskCanvasProps) {
|
|||
}
|
||||
|
||||
return <Stack spacing={2}>
|
||||
<canvas
|
||||
ref={brushRef}
|
||||
height={config.height.default}
|
||||
width={config.width.default}
|
||||
style={{
|
||||
display: 'none',
|
||||
}}
|
||||
/>
|
||||
<canvas
|
||||
ref={bufferRef}
|
||||
height={config.height.default}
|
||||
|
@ -223,14 +186,13 @@ export function MaskCanvas(props: MaskCanvasProps) {
|
|||
const canvas = mustExist(canvasRef.current);
|
||||
const bounds = canvas.getBoundingClientRect();
|
||||
|
||||
const buffer = mustExist(bufferRef.current);
|
||||
const ctx = mustExist(buffer.getContext('2d'));
|
||||
const { ctx } = getContext(bufferRef);
|
||||
ctx.fillStyle = grayToRGB(brushColor);
|
||||
|
||||
drawCircle(ctx, {
|
||||
x: event.clientX - bounds.left,
|
||||
y: event.clientY - bounds.top,
|
||||
});
|
||||
}, brushSize);
|
||||
|
||||
maskState.current = MASK_STATE.dirty;
|
||||
save();
|
||||
|
@ -242,14 +204,24 @@ export function MaskCanvas(props: MaskCanvasProps) {
|
|||
onMouseOut={finishPainting}
|
||||
onMouseUp={finishPainting}
|
||||
onMouseMove={(event) => {
|
||||
if (maskState.current === MASK_STATE.painting) {
|
||||
const canvas = mustExist(canvasRef.current);
|
||||
const bounds = canvas.getBoundingClientRect();
|
||||
|
||||
if (maskState.current === MASK_STATE.painting) {
|
||||
setClicks([...clicks, {
|
||||
x: event.clientX - bounds.left,
|
||||
y: event.clientY - bounds.top,
|
||||
}]);
|
||||
} else {
|
||||
const { ctx } = getClearContext(brushRef);
|
||||
ctx.fillStyle = grayToRGB(brushColor);
|
||||
|
||||
drawCircle(ctx, {
|
||||
x: event.clientX - bounds.left,
|
||||
y: event.clientY - bounds.top,
|
||||
}, brushSize);
|
||||
|
||||
drawBuffer();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
@ -279,20 +251,94 @@ export function MaskCanvas(props: MaskCanvasProps) {
|
|||
<Button
|
||||
variant='outlined'
|
||||
startIcon={<FormatColorFill />}
|
||||
onClick={() => floodMask(floodBelow)}>
|
||||
onClick={() => {
|
||||
floodCanvas(bufferRef, floodBelow);
|
||||
save();
|
||||
}}>
|
||||
Gray to black
|
||||
</Button>
|
||||
<Button
|
||||
variant='outlined'
|
||||
startIcon={<Gradient />}
|
||||
onClick={() => floodMask(floodGray)}>
|
||||
onClick={() => {
|
||||
floodCanvas(bufferRef, floodGray);
|
||||
save();
|
||||
}}>
|
||||
Grayscale
|
||||
</Button>
|
||||
<Button
|
||||
variant='outlined'
|
||||
startIcon={<FormatColorFill />}
|
||||
onClick={() => floodMask(floodAbove)}>
|
||||
onClick={() => {
|
||||
floodCanvas(bufferRef, floodAbove);
|
||||
save();
|
||||
}}>
|
||||
Gray to white
|
||||
</Button>
|
||||
</Stack></Stack>;
|
||||
</Stack>
|
||||
</Stack>;
|
||||
}
|
||||
|
||||
function getContext(ref: RefObject<HTMLCanvasElement>) {
|
||||
const canvas = mustExist(ref.current);
|
||||
const ctx = mustExist(canvas.getContext('2d'));
|
||||
|
||||
return { canvas, ctx };
|
||||
}
|
||||
|
||||
function getClearContext(ref: RefObject<HTMLCanvasElement>) {
|
||||
const ret = getContext(ref);
|
||||
ret.ctx.clearRect(0, 0, ret.canvas.width, ret.canvas.height);
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
function drawCircle(ctx: CanvasRenderingContext2D, point: Point, size: number): void {
|
||||
ctx.beginPath();
|
||||
ctx.arc(point.x, point.y, size, 0, FULL_CIRCLE);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
export function floodBelow(n: number): number {
|
||||
if (n < THRESHOLDS.upper) {
|
||||
return COLORS.black;
|
||||
} else {
|
||||
return COLORS.white;
|
||||
}
|
||||
}
|
||||
|
||||
export function floodAbove(n: number): number {
|
||||
if (n > THRESHOLDS.lower) {
|
||||
return COLORS.white;
|
||||
} else {
|
||||
return COLORS.black;
|
||||
}
|
||||
}
|
||||
|
||||
export function floodGray(n: number): number {
|
||||
return n;
|
||||
}
|
||||
|
||||
export function grayToRGB(n: number): string {
|
||||
return `rgb(${n.toFixed(0)},${n.toFixed(0)},${n.toFixed(0)})`;
|
||||
}
|
||||
|
||||
function floodCanvas(ref: RefObject<HTMLCanvasElement>, flood: FloodFn) {
|
||||
const { canvas, ctx } = getContext(ref);
|
||||
const image = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const pixels = image.data;
|
||||
|
||||
for (let x = 0; x < canvas.width; ++x) {
|
||||
for (let y = 0; y < canvas.height; ++y) {
|
||||
const i = (y * canvas.width * PIXEL_SIZE) + (x * PIXEL_SIZE);
|
||||
const hue = (pixels[i] + pixels[i + 1] + pixels[i + 2]) / PIXEL_WEIGHT;
|
||||
const final = flood(hue);
|
||||
|
||||
pixels[i] = final;
|
||||
pixels[i + 1] = final;
|
||||
pixels[i + 2] = final;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.putImageData(image, 0, 0);
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue