render: cropper & download image

This commit is contained in:
muerwre 2018-11-28 17:57:56 +07:00
parent 857a2a0c12
commit 34d1b85513
14 changed files with 308 additions and 40 deletions

10
package-lock.json generated
View file

@ -3630,6 +3630,11 @@
"sha.js": "^2.4.8" "sha.js": "^2.4.8"
} }
}, },
"croppr": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/croppr/-/croppr-2.3.1.tgz",
"integrity": "sha512-0rvTl4VmR3I4AahjJPF1u9IlT7ckvjIcgaLnUjYaY+UZsP9oxlVYZWYDuqM3SVCQiaI7DXMjR7wOEYT+mydOFg=="
},
"cross-spawn": { "cross-spawn": {
"version": "5.1.0", "version": "5.1.0",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz",
@ -5234,6 +5239,11 @@
"schema-utils": "^0.4.5" "schema-utils": "^0.4.5"
} }
}, },
"file-saver": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.0.tgz",
"integrity": "sha512-cYM1ic5DAkg25pHKgi5f10ziAM7RJU37gaH1XQlyNDrtUnzhC/dfoV9zf2OmF0RMKi42jG5B0JWBnPQqyj/G6g=="
},
"filename-regex": { "filename-regex": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/filename-regex/-/filename-regex-2.0.1.tgz", "resolved": "https://registry.npmjs.org/filename-regex/-/filename-regex-2.0.1.tgz",

View file

@ -50,6 +50,8 @@
"babel-runtime": "^6.26.0", "babel-runtime": "^6.26.0",
"classnames": "^2.2.6", "classnames": "^2.2.6",
"clean-webpack-plugin": "^0.1.9", "clean-webpack-plugin": "^0.1.9",
"croppr": "^2.3.1",
"file-saver": "^2.0.0",
"history": "^4.7.2", "history": "^4.7.2",
"leaflet": "^1.3.4", "leaflet": "^1.3.4",
"leaflet-editable": "^1.1.0", "leaflet-editable": "^1.1.0",

View file

@ -9,10 +9,8 @@ import { EditorDialog } from '$components/panels/EditorDialog';
import { LogoPreview } from '$components/logo/LogoPreview'; import { LogoPreview } from '$components/logo/LogoPreview';
import { bindActionCreators } from 'redux'; import { bindActionCreators } from 'redux';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { setMode, startEditing, stopEditing, setLogo, showRenderer } from '$redux/user/actions'; import { setMode, startEditing, stopEditing, setLogo, takeAShot } from '$redux/user/actions';
import type { UserType } from '$constants/types'; import type { UserType } from '$constants/types';
import { editor } from '$modules/Editor';
import { getTilePlacement } from '$utils/renderer';
type Props = { type Props = {
user: UserType, user: UserType,
@ -31,7 +29,7 @@ type Props = {
startEditing: Function, startEditing: Function,
stopEditing: Function, stopEditing: Function,
setLogo: Function, setLogo: Function,
showRenderer: Function, takeAShot: Function,
} }
class Component extends React.PureComponent<Props, void> { class Component extends React.PureComponent<Props, void> {
@ -39,8 +37,6 @@ class Component extends React.PureComponent<Props, void> {
const obj = document.getElementById('control-dialog'); const obj = document.getElementById('control-dialog');
const { width } = this.panel.getBoundingClientRect(); const { width } = this.panel.getBoundingClientRect();
console.log(obj, this.panel);
if (!this.panel || !obj) return; if (!this.panel || !obj) return;
obj.style.width = width; obj.style.width = width;
@ -109,7 +105,7 @@ class Component extends React.PureComponent<Props, void> {
<button <button
className={classnames('disabled', { active: mode === MODES.SHOTTER })} className={classnames('disabled', { active: mode === MODES.SHOTTER })}
onClick={this.props.showRenderer} onClick={this.props.takeAShot}
// onClick={getTilePlacement} // onClick={getTilePlacement}
> >
<Icon icon="icon-shot-3" /> <Icon icon="icon-shot-3" />
@ -221,7 +217,7 @@ const mapDispatchToProps = dispatch => bindActionCreators({
setLogo, setLogo,
startEditing, startEditing,
stopEditing, stopEditing,
showRenderer, takeAShot,
}, dispatch); }, dispatch);
export const EditorPanel = connect( export const EditorPanel = connect(

View file

@ -1,30 +1,148 @@
import React from 'react'; import React from 'react';
import { getPolyPlacement, getTilePlacement, composeImages, composePoly, fetchImages } from '$utils/renderer';
export class Renderer extends React.Component { import { hideRenderer, cropAShot } from '$redux/user/actions';
componentDidMount() { import { bindActionCreators } from 'redux';
if (this.canvas) this.init(); import { connect } from 'react-redux';
import Croppr from 'croppr';
import 'croppr/dist/croppr.css';
import { Icon } from '$components/panels/Icon';
import { LOGOS } from '$constants/logos';
type Props = {
data: String,
logo: String,
hideRenderer: Function,
cropAShot: Function,
};
type State = {
};
class Component extends React.Component<Props, State> {
onImageLoaded = () => {
this.croppr = new Croppr(this.image, {
onCropMove: this.moveLogo,
onInitialize: this.onCropInit,
});
};
componentWillUnmount() {
this.croppr.destroy();
} }
init() { onCropInit = (crop) => {
const ctx = this.canvas.getContext('2d');
const geometry = getTilePlacement();
const points = getPolyPlacement();
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
fetchImages(ctx, geometry)
.then(images => composeImages({ geometry, images, ctx })) const { regionEl, box } = crop;
.then(() => composePoly({ points, ctx })) const scale = ((box.x2 - box.x1) / window.innerWidth);
.then(() => this.canvas.toDataURL('image/jpeg'));
// .then(image => window.open().document.write(`<img src="${image}" />`)) console.log('CROP', crop, scale);
}
this.logo = document.createElement('div');
this.logo.className = 'renderer-logo';
this.logo.style.transform = `scale(${scale})`;
this.logoImg = document.createElement('img');
this.logoImg.src = LOGOS[this.props.logo][1];
this.logo.append(this.logoImg);
regionEl.append(this.logo);
};
moveLogo = ({ x, y, width, height }) => {
if (!this.logo) return;
this.logo.style.color = 'blue';
};
croppr;
getImage = () => {
this.props.cropAShot(this.croppr.getValue());
};
render() { render() {
const { data } = this.props;
const { innerWidth, innerHeight } = window;
const padding = 30;
const paddingBottom = 80;
let width;
let height;
// if (innerWidth > innerHeight) {
height = innerHeight - padding - paddingBottom;
width = height * (innerWidth / innerHeight);
// }
return ( return (
<div className="renderer-shade" onClick={this.props.onClick}> <div>
<canvas width={window.innerWidth} height={window.innerHeight} ref={el => { this.canvas = el; }} /> <div
className="renderer-shade"
style={{
padding,
paddingBottom,
}}
>
<div
style={{ width, height }}
>
<img
src={data}
alt=""
id="rendererOutput"
ref={el => { this.image = el; }}
onLoad={this.onImageLoaded}
/>
</div>
</div>
<div
className="panel active"
style={{
zIndex: 1000,
left: '50%',
right: 'auto',
transform: 'translateX(-50%)',
}}
>
<div className="control-bar control-bar-padded">
<button>
<Icon icon="icon-logo-3" />
</button>
</div>
<div className="control-sep" />
<div className="control-bar">
<button
className="highlighted cancel"
onClick={this.props.hideRenderer}
>
<Icon icon="icon-cancel-1" />
</button>
<button
className="success"
onClick={this.getImage}
>
<span>СКАЧАТЬ</span>
<Icon icon="icon-get-1" />
</button>
</div>
</div>
</div> </div>
); );
} }
} }
const mapStateToProps = state => ({ ...state.user.renderer, logo: state.user.logo });
const mapDispatchToProps = dispatch => bindActionCreators({
hideRenderer,
cropAShot,
}, dispatch);
export const Renderer = connect(mapStateToProps, mapDispatchToProps)(Component);

View file

@ -26,9 +26,16 @@
padding: 0; padding: 0;
margin: 0; margin: 0;
} }
canvas#renderer {
position: fixed;
left: 0;
top: 0;
}
</style> </style>
</head> </head>
<body> <body>
<canvas id="renderer"></canvas>
<section id="map" style="position: absolute; width: 100%; height: 100%;"></section> <section id="map" style="position: absolute; width: 100%; height: 100%;"></section>
<section id="loader"></section> <section id="loader"></section>
<section id="index"></section> <section id="index"></section>

View file

@ -1,11 +1,14 @@
/* /*
todo shot mechanism (50%)
done client-side shot mechanism
todo croppr.js
todo shot stickers
todo hotkeys via sagas todo hotkeys via sagas
todo shot mechanism
todo crop mechanism
todo map catalogue todo map catalogue
todo map preview on save
todo tooltips todo tooltips
todo client-side shot mechanism
*/ */
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';

View file

@ -34,3 +34,7 @@ export const setSaveOverwrite = () => ({ type: ACTIONS.SET_SAVE_OVERWRITE });
export const showRenderer = () => ({ type: ACTIONS.SHOW_RENDERER }); export const showRenderer = () => ({ type: ACTIONS.SHOW_RENDERER });
export const hideRenderer = () => ({ type: ACTIONS.HIDE_RENDERER }); export const hideRenderer = () => ({ type: ACTIONS.HIDE_RENDERER });
export const setRenderer = payload => ({ type: ACTIONS.SET_RENDERER, payload });
export const takeAShot = () => ({ type: ACTIONS.TAKE_A_SHOT });
export const cropAShot = payload => ({ type: ACTIONS.CROP_A_SHOT, ...payload });

View file

@ -33,4 +33,7 @@ export const ACTIONS = {
SHOW_RENDERER: 'SHOW_RENDERER', SHOW_RENDERER: 'SHOW_RENDERER',
HIDE_RENDERER: 'HIDE_RENDERER', HIDE_RENDERER: 'HIDE_RENDERER',
SET_RENDERER: 'SET_RENDERER',
TAKE_A_SHOT: 'TAKE_A_SHOT',
CROP_A_SHOT: 'CROP_A_SHOT',
}; };

View file

@ -65,6 +65,11 @@ const hideRenderer = state => ({
renderer: { ...state.renderer, renderer_active: false } renderer: { ...state.renderer, renderer_active: false }
}); });
const setRenderer = (state, { payload }) => ({
...state,
renderer: { ...state.renderer, ...payload }
});
const HANDLERS = { const HANDLERS = {
[ACTIONS.SET_USER]: setUser, [ACTIONS.SET_USER]: setUser,
[ACTIONS.SET_EDITING]: setEditing, [ACTIONS.SET_EDITING]: setEditing,
@ -85,6 +90,7 @@ const HANDLERS = {
[ACTIONS.SHOW_RENDERER]: showRenderer, [ACTIONS.SHOW_RENDERER]: showRenderer,
[ACTIONS.HIDE_RENDERER]: hideRenderer, [ACTIONS.HIDE_RENDERER]: hideRenderer,
[ACTIONS.SET_RENDERER]: setRenderer,
}; };
export const INITIAL_STATE = { export const INITIAL_STATE = {

View file

@ -3,10 +3,11 @@ import { delay } from 'redux-saga';
import { takeLatest, select, call, put, takeEvery, race, take } from 'redux-saga/effects'; import { takeLatest, select, call, put, takeEvery, race, take } from 'redux-saga/effects';
import { checkUserToken, getGuestToken, getStoredMap, postMap } from '$utils/api'; import { checkUserToken, getGuestToken, getStoredMap, postMap } from '$utils/api';
import { import {
hideRenderer,
setActiveSticker, setAddress, setActiveSticker, setAddress,
setChanged, setChanged,
setEditing, setEditing,
setMode, setMode, setRenderer,
setSaveError, setSaveError,
setSaveOverwrite, setSaveSuccess, setTitle, setSaveOverwrite, setSaveSuccess, setTitle,
setUser setUser
@ -17,6 +18,15 @@ import { ACTIONS } from '$redux/user/constants';
import { MODES } from '$constants/modes'; import { MODES } from '$constants/modes';
import { DEFAULT_USER } from '$constants/auth'; import { DEFAULT_USER } from '$constants/auth';
import { TIPS } from '$constants/tips'; import { TIPS } from '$constants/tips';
import {
composeImages,
composePoly, downloadCanvas,
fetchImages,
getPolyPlacement,
getTilePlacement,
imageFetcher
} from '$utils/renderer';
import { LOGOS } from '$constants/logos';
const getUser = state => (state.user.user); const getUser = state => (state.user.user);
const getState = state => (state.user); const getState = state => (state.user);
@ -217,6 +227,61 @@ function* setSaveSuccessSaga({ address, title }) {
return yield editor.setInitialData(); return yield editor.setInitialData();
} }
function* getRenderData() {
const canvas = document.getElementById('renderer');
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const ctx = canvas.getContext('2d');
const geometry = getTilePlacement();
const points = getPolyPlacement();
ctx.clearRect(0, 0, canvas.width, canvas.height);
const images = yield fetchImages(ctx, geometry);
yield composeImages({ geometry, images, ctx });
yield composePoly({ points, ctx });
return yield canvas.toDataURL('image/jpeg');
}
function* takeAShotSaga() {
const data = yield call(getRenderData);
yield put(setRenderer({
data, renderer_active: true, width: window.innerWidth, height: window.innerHeight
}));
return true;
}
function* getCropData({
x, y, width, height
}) {
const { logo, renderer: { data } } = yield select(getState);
const canvas = document.getElementById('renderer');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
const image = yield imageFetcher(data);
const logoImage = yield imageFetcher(LOGOS[logo][1]);
ctx.drawImage(image, -x, -y);
ctx.drawImage(logoImage, width - logoImage.width, height - logoImage.height);
return yield canvas.toDataURL('image/jpeg');
}
function* cropAShotSaga(params) {
const { title, address } = yield select(getState);
yield call(getCropData, params);
const canvas = document.getElementById('renderer');
downloadCanvas(canvas, (title || address));
return yield put(hideRenderer());
}
export function* userSaga() { export function* userSaga() {
// ASYNCHRONOUS!!! :-) // ASYNCHRONOUS!!! :-)
@ -241,4 +306,6 @@ export function* userSaga() {
yield takeLatest(ACTIONS.SEND_SAVE_REQUEST, sendSaveRequestSaga); yield takeLatest(ACTIONS.SEND_SAVE_REQUEST, sendSaveRequestSaga);
yield takeLatest(ACTIONS.SET_SAVE_SUCCESS, setSaveSuccessSaga); yield takeLatest(ACTIONS.SET_SAVE_SUCCESS, setSaveSuccessSaga);
yield takeLatest(ACTIONS.TAKE_A_SHOT, takeAShotSaga);
yield takeLatest(ACTIONS.CROP_A_SHOT, cropAShotSaga);
} }

View file

@ -293,8 +293,14 @@
<path xmlns="http://www.w3.org/2000/svg" d="M23 8c0 1.1-.9 2-2 2-.18 0-.35-.02-.51-.07l-3.56 3.55c.05.16.07.34.07.52 0 1.1-.9 2-2 2s-2-.9-2-2c0-.18.02-.36.07-.52l-2.55-2.55c-.16.05-.34.07-.52.07s-.36-.02-.52-.07l-4.55 4.56c.05.16.07.33.07.51 0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2c.18 0 .35.02.51.07l4.56-4.55C8.02 9.36 8 9.18 8 9c0-1.1.9-2 2-2s2 .9 2 2c0 .18-.02.36-.07.52l2.55 2.55c.16-.05.34-.07.52-.07s.36.02.52.07l3.55-3.56C19.02 8.35 19 8.18 19 8c0-1.1.9-2 2-2s2 .9 2 2zm0 0c0 1.1-.9 2-2 2-.18 0-.35-.02-.51-.07l-3.56 3.55c.05.16.07.34.07.52 0 1.1-.9 2-2 2s-2-.9-2-2c0-.18.02-.36.07-.52l-2.55-2.55c-.16.05-.34.07-.52.07s-.36-.02-.52-.07l-4.55 4.56c.05.16.07.33.07.51 0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2c.18 0 .35.02.51.07l4.56-4.55C8.02 9.36 8 9.18 8 9c0-1.1.9-2 2-2s2 .9 2 2c0 .18-.02.36-.07.52l2.55 2.55c.16-.05.34-.07.52-.07s.36.02.52.07l3.55-3.56C19.02 8.35 19 8.18 19 8c0-1.1.9-2 2-2s2 .9 2 2z" fill="white" stroke="white" stroke-width="1" transform="translate(4 4)"/> <path xmlns="http://www.w3.org/2000/svg" d="M23 8c0 1.1-.9 2-2 2-.18 0-.35-.02-.51-.07l-3.56 3.55c.05.16.07.34.07.52 0 1.1-.9 2-2 2s-2-.9-2-2c0-.18.02-.36.07-.52l-2.55-2.55c-.16.05-.34.07-.52.07s-.36-.02-.52-.07l-4.55 4.56c.05.16.07.33.07.51 0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2c.18 0 .35.02.51.07l4.56-4.55C8.02 9.36 8 9.18 8 9c0-1.1.9-2 2-2s2 .9 2 2c0 .18-.02.36-.07.52l2.55 2.55c.16-.05.34-.07.52-.07s.36.02.52.07l3.55-3.56C19.02 8.35 19 8.18 19 8c0-1.1.9-2 2-2s2 .9 2 2zm0 0c0 1.1-.9 2-2 2-.18 0-.35-.02-.51-.07l-3.56 3.55c.05.16.07.34.07.52 0 1.1-.9 2-2 2s-2-.9-2-2c0-.18.02-.36.07-.52l-2.55-2.55c-.16.05-.34.07-.52.07s-.36-.02-.52-.07l-4.55 4.56c.05.16.07.33.07.51 0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2c.18 0 .35.02.51.07l4.56-4.55C8.02 9.36 8 9.18 8 9c0-1.1.9-2 2-2s2 .9 2 2c0 .18-.02.36-.07.52l2.55 2.55c.16-.05.34-.07.52-.07s.36.02.52.07l3.55-3.56C19.02 8.35 19 8.18 19 8c0-1.1.9-2 2-2s2 .9 2 2z" fill="white" stroke="white" stroke-width="1" transform="translate(4 4)"/>
</g> </g>
<g id="icon-get-1" stroke="none">
<path stroke-opacity=".941" stroke-width=".265" d="M0 0h32v32H0z" fill="black"/>
<path xmlns="http://www.w3.org/2000/svg" d="M20 12l-1.41-1.41L13 16.17V4h-2v12.17l-5.58-5.59L4 12l8 8 8-8z" fill="white" stroke="white" stroke-width="1" transform="translate(4 4)"/>
</g>
</svg> </svg>
</defs> </defs>
<use xlink:href="#icon-shot-3" /> <use xlink:href="#icon-get-1" />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Before After
Before After

View file

@ -117,6 +117,11 @@
background-size: 100% 100%; background-size: 100% 100%;
} }
&.success {
background: linear-gradient(150deg, @green_primary, @green_secondary) 50% 50% no-repeat;
background-size: 100% 100%;
}
&.danger { &.danger {
background: linear-gradient(150deg, @red_primary, @red_secondary) 50% 50% no-repeat; background: linear-gradient(150deg, @red_primary, @red_secondary) 50% 50% no-repeat;
background-size: 100% 100%; background-size: 100% 100%;

View file

@ -5,11 +5,48 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
z-index: 1000; z-index: 1000;
background: rgba(0, 0, 0, 0.9);
display: flex;
align-items: center;
justify-content: center;
padding-bottom: 80px;
box-sizing: border-box;
background: rgba(0, 0, 0, 0.5); > div {
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
//canvas { img#rendererOutput {
// width: 50vw; width: 300px;
// height: 50vh; height: 300px;
//} }
.croppr-region {
box-shadow: rgba(255, 255, 255, 0.2) 0 0 0 6px, rgba(0, 0, 0, 0.3) 0 0 0 1px;
border: none;
position: absolute;
top: 0;
left: 0;
overflow: hidden;
}
.croppr-handle {
width: 12px;
height: 12px;
border-radius: 8px;
border: none;
box-shadow: rgba(0, 0, 0, 0.3) 0 0 0 1px;
}
}
.renderer-logo {
position: absolute;
bottom: 0;
right: 0;
pointer-events: none;
transform-origin: 100% 100%;
} }

View file

@ -1,10 +1,9 @@
import { editor } from '$modules/Editor'; import { editor } from '$modules/Editor';
import { COLORS, CONFIG } from '$config'; import { COLORS, CONFIG } from '$config';
import saveAs from 'file-saver';
const { map } = editor.map;
map.addEventListener('mousedown', ({ latlng }) => console.log('CLICK', latlng));
const latLngToTile = latlng => { const latLngToTile = latlng => {
const { map } = editor.map;
const zoom = map.getZoom(); const zoom = map.getZoom();
const xtile = parseInt(Math.floor((latlng.lng + 180) / 360 * (1 << zoom))); const xtile = parseInt(Math.floor((latlng.lng + 180) / 360 * (1 << zoom)));
const ytile = parseInt(Math.floor((1 - Math.log(Math.tan(latlng.lat * Math.PI / 180) + 1 / Math.cos(latlng.lat * Math.PI / 180)) / Math.PI) / 2 * (1 << zoom))); const ytile = parseInt(Math.floor((1 - Math.log(Math.tan(latlng.lat * Math.PI / 180) + 1 / Math.cos(latlng.lat * Math.PI / 180)) / Math.PI) / 2 * (1 << zoom)));
@ -13,6 +12,7 @@ const latLngToTile = latlng => {
}; };
const tileToLatLng = point => { const tileToLatLng = point => {
const { map } = editor.map;
const z = map.getZoom(); const z = map.getZoom();
const lng = (point.x / Math.pow(2, z) * 360 - 180); const lng = (point.x / Math.pow(2, z) * 360 - 180);
const n = Math.PI - 2 * Math.PI * point.y / Math.pow(2, z); const n = Math.PI - 2 * Math.PI * point.y / Math.pow(2, z);
@ -22,6 +22,7 @@ const tileToLatLng = point => {
}; };
export const getTilePlacement = () => { export const getTilePlacement = () => {
const { map } = editor.map;
const width = window.innerWidth; const width = window.innerWidth;
const height = window.innerHeight; const height = window.innerHeight;
@ -60,12 +61,12 @@ export const getTilePlacement = () => {
export const getPolyPlacement = () => ( export const getPolyPlacement = () => (
(!editor.poly.poly || !editor.poly.poly.getLatLngs() || editor.poly.poly.getLatLngs().length <= 0) (!editor.poly.poly || !editor.poly.poly.getLatLngs() || editor.poly.poly.getLatLngs().length <= 0)
? [] ? []
: editor.poly.poly.getLatLngs().map((latlng) => ({ ...map.latLngToContainerPoint(latlng) })) : editor.poly.poly.getLatLngs().map((latlng) => ({ ...editor.map.map.latLngToContainerPoint(latlng) }))
); );
const getImageSource = ({ x, y, zoom }) => (`http://b.basemaps.cartocdn.com/light_all/${zoom}/${x}/${y}.png`); const getImageSource = ({ x, y, zoom }) => (`http://b.basemaps.cartocdn.com/light_all/${zoom}/${x}/${y}.png`);
const imageFetcher = source => new Promise((resolve, reject) => { export const imageFetcher = source => new Promise((resolve, reject) => {
const img = new Image(); const img = new Image();
img.crossOrigin = 'anonymous'; img.crossOrigin = 'anonymous';
img.onload = () => resolve(img); img.onload = () => resolve(img);
@ -140,3 +141,6 @@ export const composePoly = ({ points, ctx }) => {
return true; return true;
}; };
export const downloadCanvas = (canvas, title) => canvas.toBlob(blob => saveAs(blob, title));