src/pages/paint/PaintCanvas.tsx
import { Color } from 'csstype';
import * as React from 'react';
import { AnimationFrameId, appSpace, between, emptyPos, IPos, IPosPair, Ratio } from '../../misc';
import MultiPointerHandler from '../../services/MultiPointerHandler';
import './PaintCanvas.css';
export interface IPaintCanvasProps {
height: number;
imageHeight: number;
imageWidth: number;
inactive: boolean;
onCanvasReceive: (el: HTMLCanvasElement | null) => void;
onCanvasUpdated: (imageData: ImageData) => void;
onLongPoint: () => void;
originalImage?: HTMLImageElement;
strokeColor: Color;
strokeWidth: number;
width: number;
}
interface IPaintCanvasState {
dScale: number;
dTranslation: IPos;
lining: boolean;
pinching: boolean;
scale: number;
translation: IPos;
}
class PaintCanvas extends React.Component<IPaintCanvasProps, IPaintCanvasState> {
protected refCanvas = React.createRef<HTMLCanvasElement>();
protected tmPressing: AnimationFrameId = 0;
protected lastPos: IPos = emptyPos;
protected lined = false;
protected lastImage: ImageData;
protected pinchStartedPos: IPosPair = [emptyPos, emptyPos];
protected pinchCenter: IPos = emptyPos;
protected pinchDistance = 0;
protected canvasOffset: IPos = emptyPos;
protected el = React.createRef<HTMLDivElement>();
protected pointerHandler: MultiPointerHandler;
protected vCtx: CanvasRenderingContext2D | null = null;
protected get ctx (): CanvasRenderingContext2D | null {
if (!this.vCtx) {
const el = this.refCanvas.current;
if (!el) {
return null;
}
this.vCtx = el.getContext('2d');
}
return this.vCtx;
}
protected get styles (): React.CSSProperties {
return {
filter: this.props.inactive ? 'blur(5px)' : '',
height: this.props.height,
width: this.props.width,
};
}
protected get canvasStyle (): React.CSSProperties {
const scale = this.pinchingScale;
const translation = this.pinchingTranslation;
const transform = [
`translate(${translation.x}px, ${translation.y}px)`,
`scale(${scale})`,
].join(' ');
return {
transform,
};
}
private get pinchingScale (): Ratio {
return this.state.scale * this.state.dScale;
}
private get safeMinScale (): Ratio {
return Math.min(
1,
(this.props.width - appSpace * 2) / this.props.imageWidth,
(this.props.height - appSpace * 2) / this.props.imageHeight,
);
}
protected get pinchingTranslation (): IPos {
return {
x: this.state.translation.x + this.state.dTranslation.x,
y: this.state.translation.y + this.state.dTranslation.y,
};
}
constructor (props: IPaintCanvasProps) {
super(props);
this.state = {
dScale: 1,
dTranslation: emptyPos,
lining: false,
pinching: false,
scale: 1,
translation: emptyPos,
};
this.lastImage = this.createEmptyImageData();
this.onPointStart = this.onPointStart.bind(this);
this.onPointMove = this.onPointMove.bind(this);
this.onPointEnd = this.onPointEnd.bind(this);
this.onPointCancel = this.onPointCancel.bind(this);
this.onLongPoint = this.onLongPoint.bind(this);
this.onPinchStart = this.onPinchStart.bind(this);
this.onPinchMove = this.onPinchMove.bind(this);
this.onPinchEnd = this.onPinchEnd.bind(this);
this.pointerHandler = new MultiPointerHandler({
// debug: window.location.search.slice(1).split('&').includes('point=1'),
onLongPoint: this.onLongPoint,
onPinchEnd: this.onPinchEnd,
onPinchMove: this.onPinchMove,
onPinchStart: this.onPinchStart,
onPointCancel: this.onPointCancel,
onPointEnd: this.onPointEnd,
onPointMove: this.onPointMove,
onPointStart: this.onPointStart,
});
}
public render () {
const sizeClassName = [
'PaintCanvas-size',
this.state.pinching ? '-active' : undefined,
].join(' ');
const canvasClassName = [
'PaintCanvas-canvas',
this.state.lining || this.state.pinching && '-active',
].join(' ');
return (
<div className="PaintCanvas"
ref={this.el}
style={this.styles}
>
<canvas className={canvasClassName}
style={this.canvasStyle}
width={this.props.imageWidth}
height={this.props.imageHeight}
ref={this.refCanvas}
/>
<div className={sizeClassName}>
x{this.pinchingScale.toFixed(2)}
</div>
</div>
);
}
public componentWillMount () {
const scale = Math.max(this.safeMinScale, 0);
const translation = this.getSafeTranslation(scale);
this.setState({
scale,
translation,
});
}
public componentDidMount () {
const el = this.el.current;
if (el) {
this.pointerHandler.start(el);
}
const { ctx } = this;
if (ctx) {
if (this.props.originalImage) {
ctx.drawImage(this.props.originalImage, 0, 0);
} else {
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, this.props.imageWidth, this.props.imageHeight);
}
}
this.props.onCanvasReceive(this.refCanvas.current);
}
public componentWillUnmount () {
this.pointerHandler.stop();
this.props.onCanvasReceive(null);
}
protected onPointStart (pos: IPos) {
this.startLining(pos);
}
protected onPointMove (pos: IPos) {
if (this.state.lining) {
this.drawLine(pos);
}
}
protected onPointEnd () {
if (this.state.lining) {
this.stopLining();
}
}
protected onPointCancel () {
if (this.state.lining) {
this.cancelLining();
}
}
/**
* @see #progressPressing
*/
protected onLongPoint () {
if (this.state.lining) {
this.cancelLining();
}
this.props.onLongPoint();
}
protected onPinchStart (posPair: IPosPair) {
this.startPinching(posPair);
}
protected onPinchMove (posPair: IPosPair) {
this.pinch(posPair);
}
protected onPinchEnd () {
this.stopPinching();
}
protected createEmptyImageData (): ImageData {
return new ImageData(1, 1);
}
protected startLining (pos: IPos) {
const elCanvas = this.refCanvas.current;
const { ctx } = this;
if (!elCanvas || !ctx) {
return;
}
this.canvasOffset = {
x: elCanvas.offsetLeft,
y: elCanvas.offsetTop,
};
const canvasPos = this.convertToCanvasPos(pos);
ctx.beginPath();
ctx.strokeStyle = this.props.strokeColor;
ctx.lineWidth = this.props.strokeWidth;
ctx.lineCap = 'round';
ctx.moveTo(canvasPos.x, canvasPos.y);
this.lined = false;
this.lastPos = canvasPos;
this.setState({
lining: true,
});
}
protected drawLine (pos: IPos) {
const { ctx } = this;
if (!ctx) {
return;
}
const canvasPos = this.convertToCanvasPos(pos);
const lx = this.lastPos.x;
const ly = this.lastPos.y;
ctx.quadraticCurveTo(
lx,
ly,
(lx + canvasPos.x) / 2,
(ly + canvasPos.y) / 2,
);
ctx.stroke();
this.lined = true;
this.lastPos = canvasPos;
}
protected stopLining (lastStroke?: boolean) {
const { ctx } = this;
if (!ctx) {
return;
}
if (lastStroke !== false && this.lined) {
ctx.lineTo(
this.lastPos.x,
this.lastPos.y,
);
ctx.stroke();
}
this.recordHistory();
this.setState({
lining: false,
});
}
protected cancelLining () {
this.restoreLastImage();
this.stopLining(false);
}
/**
* Make sure `canvasOffset` is up to date before call.
*/
protected convertToCanvasPos (screenPos: IPos) {
const { canvasOffset } = this;
const { scale, translation } = this.state;
const canvasPos = {
x: (-translation.x - canvasOffset.x + screenPos.x) / scale,
y: (-translation.y - canvasOffset.y + screenPos.y) / scale,
};
return canvasPos;
}
protected recordHistory () {
if (!this.ctx) {
throw new Error('Canvas is not ready');
}
const { imageHeight, imageWidth } = this.props;
this.lastImage = this.ctx.getImageData(0, 0, imageWidth, imageHeight);
if (this.props.onCanvasUpdated) {
this.props.onCanvasUpdated(this.lastImage);
}
}
protected restoreLastImage () {
if (!this.ctx) {
throw new Error('Canvas is not ready');
}
this.ctx.putImageData(this.lastImage, 0, 0);
}
protected startPinching (posPair: IPosPair) {
this.cancelLining();
this.pinchStartedPos = posPair;
this.pinchCenter = this.calculateCenter(posPair);
this.pinchDistance = this.calculateDistance(posPair);
this.setState({
pinching: true,
});
}
protected pinch (posPair: IPosPair) {
const dScale = this.calculateDistance(posPair) / this.pinchDistance;
const c0 = this.pinchCenter;
const c1 = this.calculateCenter(posPair);
const f0 = this.state.translation;
const dTranslation = {
x: (f0.x - c0.x) * dScale + c1.x - f0.x,
y: (f0.y - c0.y) * dScale + c1.y - f0.y,
};
this.setState({ dScale, dTranslation });
}
protected stopPinching () {
const scale = Math.max(this.safeMinScale, this.pinchingScale);
const translation = this.getSafeTranslation(scale);
this.setState({
dScale: 1,
dTranslation: emptyPos,
pinching: false,
scale,
translation,
});
}
protected getSafeTranslation (scale: Ratio): IPos {
const p = this.props;
const t = this.pinchingTranslation;
const safePos = {
x: this.calculateSafeTransitionPos(p.imageWidth, p.width, t.x, scale),
y: this.calculateSafeTransitionPos(p.imageHeight, p.height, t.y, scale),
};
return safePos;
}
protected calculateSafeTransitionPos (imageSize: number, size: number, transition: number, scale: Ratio) {
if (imageSize * scale < size - appSpace * 2) {
return (size - imageSize * scale) / 2;
} else {
const max = appSpace;
const min = -imageSize * scale + (size - appSpace);
return between(min, transition, max);
}
}
protected calculateCenter (positions: IPos[]) {
if (positions.length !== 2) {
throw new Error(`2 positions must be given but ${positions.length}`);
}
const [p1, p2] = positions;
const center: IPos = {
x: (p1.x + p2.x) / 2,
y: (p1.y + p2.y) / 2,
};
return center;
}
protected calculateDistance (positions: IPos[]) {
if (positions.length !== 2) {
throw new Error(`2 positions must be given but ${positions.length}`);
}
const [p1, p2] = positions;
const distance = Math.sqrt((p1.x - p2.x) ** 2 + (p1.y - p2.y) ** 2);
return distance;
}
}
export default PaintCanvas;