src/pages/paint/PaintPage.tsx
// import * as firebase from 'firebase';
import { Color } from 'csstype';
import * as React from 'react';
import { connect } from 'react-redux';
import BubbleButton from '../../components/BubbleButton';
import { appHistory, appSpace, CanvasType, defaultStrokeColors, defaultStrokeWidth, getCanvasType, getUrlParamOf, ISize } from '../../misc';
import firebase from '../../plugins/firebase';
import * as processing from '../../reducers/processing';
import CanvasHistory, { HistoryRecord, HistoryRecordType, IImageHistoryRecord } from '../../services/CanvasHistory';
import { getImageUrl, loadImage, readBlob, uploadImage } from '../../services/image';
import * as paths from '../../services/paths';
import * as user from '../../services/user';
import PaintCanvas from './PaintCanvas';
import AppMenu from './PaintMenu';
import './PaintPage.css';
interface IPaintPagePros {
startProcessing: () => () => void;
}
interface IPaintPageState {
dirty: boolean;
height: number;
imageLoading: boolean;
imageSize: ISize;
menuVisible: boolean;
originalImage?: HTMLImageElement;
strokeColor: Color;
strokeWidth: number;
width: number;
}
class PaintPage extends React.Component<IPaintPagePros, IPaintPageState> {
protected currentUser: firebase.User | null = null;
protected elCanvas: HTMLCanvasElement | null = null;
protected storageRef = firebase.storage().ref('v1-images');
protected canvasType = '';
protected canvasHistory = new CanvasHistory();
constructor (props: IPaintPagePros) {
super(props);
this.state = {
dirty: false,
height: 0,
imageLoading: false,
imageSize: {
height: 0,
width: 0,
},
menuVisible: false,
originalImage: undefined,
strokeColor: defaultStrokeColors,
strokeWidth: defaultStrokeWidth,
width: 0,
};
this.onBeforeUnload = this.onBeforeUnload.bind(this);
this.onDocumentTouchStart = this.onDocumentTouchStart.bind(this);
this.onUndoClick = this.onUndoClick.bind(this);
this.onRedoClick = this.onRedoClick.bind(this);
this.onCanvasReceive = this.onCanvasReceive.bind(this);
this.onCanvasUpdated = this.onCanvasUpdated.bind(this);
this.onCanvasLongTap = this.onCanvasLongTap.bind(this);
this.onMenuOverlayClick = this.onMenuOverlayClick.bind(this);
this.onStrokeWidthChange = this.onStrokeWidthChange.bind(this);
this.onColorChange = this.onColorChange.bind(this);
this.onSave = this.onSave.bind(this);
this.onNew = this.onNew.bind(this);
}
public render () {
return (
<div className="PaintPage">
{!this.state.imageLoading && <PaintCanvas
height={this.state.height}
imageHeight={this.state.imageSize.height}
imageWidth={this.state.imageSize.width}
inactive={this.state.menuVisible}
originalImage={this.state.originalImage}
strokeColor={this.state.strokeColor}
strokeWidth={this.state.strokeWidth}
width={this.state.width}
onCanvasReceive={this.onCanvasReceive}
onCanvasUpdated={this.onCanvasUpdated}
onLongPoint={this.onCanvasLongTap}
/>}
<BubbleButton
initialLeft={0}
onPress={this.onUndoClick}
>
<i className="fa fa-undo" aria-hidden="true"/>
</BubbleButton>
<BubbleButton
onPress={this.onRedoClick}
>
<i className="fa fa-repeat" aria-hidden="true"/>
</BubbleButton>
<AppMenu
strokeColor={this.state.strokeColor}
strokeWidth={this.state.strokeWidth}
visible={this.state.menuVisible}
onOverlayClick={this.onMenuOverlayClick}
onStrokeWidthChange={this.onStrokeWidthChange}
onColorChange={this.onColorChange}
onSave={this.onSave}
onNew={this.onNew}
/>
</div>
);
}
public async componentWillMount () {
this.setUpSizes();
if (!firebase.auth().currentUser) {
try {
await firebase.auth().signInAnonymously();
} catch (error) {
const detail = JSON.parse(error.message);
throw new Error(detail.error.message);
}
}
this.currentUser = firebase.auth().currentUser;
user.saveLogin(this.currentUser!.uid);
document.addEventListener('touchstart', this.onDocumentTouchStart, { passive: false });
window.addEventListener('beforeunload', this.onBeforeUnload);
}
public componentDidMount () {
if (this.canvasType === CanvasType.history) {
const uid = getUrlParamOf('uid', '');
const imageId = getUrlParamOf('id', '');
this.loadImageFromHistory(uid, imageId);
} else if (this.canvasType === CanvasType.upload) {
this.loadImageFromDisk();
}
}
public componentWillUnmount () {
document.removeEventListener('touchstart', this.onDocumentTouchStart);
window.removeEventListener('beforeunload', this.onBeforeUnload);
}
protected onBeforeUnload (event: BeforeUnloadEvent) {
if (this.state.dirty) {
event.preventDefault();
// Chrome doesn't support preventDefault even not support message
// (remove this when it supports preventDefault way)
event.returnValue = 'Are you sure?';
}
}
protected onDocumentTouchStart (event: TouchEvent) {
// prevent from zooming
if (event.touches.length >= 2) {
event.preventDefault();
}
}
protected onUndoClick () {
const record = this.canvasHistory.goPrev();
this.restoreHistoryRecord(record);
}
protected onRedoClick () {
const record = this.canvasHistory.goNext();
this.restoreHistoryRecord(record);
}
protected onCanvasReceive (el: HTMLCanvasElement | null) {
this.elCanvas = el;
const ctx = el && el.getContext('2d');
if (ctx) {
const imageData = ctx.getImageData(0, 0, el!.width, el!.height);
this.canvasHistory.pushImageData(imageData);
} else {
this.canvasHistory.clear();
}
}
protected onCanvasUpdated (imageData: ImageData) {
this.canvasHistory.pushImageData(imageData);
if (!this.state.dirty) {
this.setState({
dirty: true,
});
}
}
protected onCanvasLongTap () {
this.setState({
menuVisible: true,
});
}
protected onMenuOverlayClick () {
this.setState({
menuVisible: false,
});
}
protected onStrokeWidthChange (width: number) {
this.setState({
strokeWidth: width,
});
}
protected onColorChange (color: Color) {
this.setState({
strokeColor: color,
});
}
protected async onSave () {
if (!this.elCanvas) {
throw new Error('Canvas is not ready');
}
const stop = this.props.startProcessing();
try {
await uploadImage({
blob: await readBlob(this.elCanvas),
height: this.elCanvas.height,
uid: this.currentUser!.uid,
width: this.elCanvas.width,
});
} catch (error) {
stop();
throw error;
}
this.setState({
dirty: false,
});
appHistory.push(paths.historyPage);
}
protected onNew () {
this.setState({
dirty: false,
});
appHistory.push(paths.newPage);
}
protected setUpSizes () {
// this is called only from `componentWillMount()`
const el = document.documentElement!;
const screenHeight = el.clientHeight;
const screenWidth = el.clientWidth;
this.setState({
height: screenHeight,
width: screenWidth,
});
let imageSize: ISize | null = null;
const type = getCanvasType();
if (type) {
this.canvasType = type;
if (type === CanvasType.size) {
imageSize = {
height: Number(getUrlParamOf('height')) || 1,
width: Number(getUrlParamOf('width')) || 1,
};
} else if (type === CanvasType.history || type === CanvasType.upload) {
// see `loadImageFromHistory()` and `loadImageFromDisk()`
this.setState({
imageLoading: true,
});
} else {
console.warn('Invalid parameters', type);
}
}
if (!imageSize) {
imageSize = {
height: screenHeight - appSpace * 2,
width: screenWidth - appSpace * 2,
};
}
this.setState({
imageSize,
});
}
protected async loadImageFromHistory (uid: string, imageId: string) {
if (!uid || !imageId) {
throw new Error('User ID and image ID must be given');
}
const url = await getImageUrl(uid, imageId);
try {
const image = await loadImage(url);
this.setState({
imageLoading: false,
imageSize: {
height: image.naturalHeight,
width: image.naturalWidth,
},
originalImage: image,
});
} catch (error) {
throw error;
}
}
protected async loadImageFromDisk () {
const imageBlob: Blob | null = appHistory.location.state.imageBlob;
if (!imageBlob) {
// TODO fail more gracefully
throw new Error('Upload type must come with image');
}
const image = await loadImage(imageBlob);
this.setState({
imageLoading: false,
imageSize: {
height: image.naturalHeight,
width: image.naturalWidth,
},
originalImage: image,
});
}
protected restoreHistoryRecord (record: HistoryRecord | null) {
if (!record) {
// do nothing
} else if (record.type === HistoryRecordType.canvas) {
this.restoreImageFromRecord(record);
}
}
protected restoreImageFromRecord (record: IImageHistoryRecord) {
const ctx = this.elCanvas && this.elCanvas.getContext('2d');
if (!ctx) {
throw new Error('Element is not ready');
}
ctx.putImageData(record.imageData, 0, 0);
}
}
const mapDispatchToProps = (dispatch: any) => ({
startProcessing: () => processing.dispatchStart(dispatch),
});
export default connect(null, mapDispatchToProps)(PaintPage);