Create sharable modal component. Implemented initial image export

This commit is contained in:
Igor Zinken
2020-12-17 10:44:10 +01:00
parent 6c78ed1968
commit f0b5fc4141
14 changed files with 447 additions and 84 deletions

View File

@@ -45,9 +45,25 @@
@click="requestDocumentClose()"
></button>
</li>
<li><button v-t="'loadDocument'"></button></li>
<li><button v-t="'saveDocument'"></button></li>
<li><button v-t="'exportImage'"></button></li>
<li>
<button v-t="'loadDocument'"
type="button"
disabled
></button>
</li>
<li>
<button v-t="'saveDocument'"
type="button"
:disabled="true/*!documents.length*/"
></button>
</li>
<li>
<button v-t="'exportImage'"
type="button"
:disabled="!documents.length"
@click="requestImageExport()"
></button>
</li>
</ul>
</li>
<li>
@@ -55,6 +71,7 @@
<ul class="submenu">
<li>
<button v-t="'resizeDocument'"
type="button"
:disabled="!documents.length"
@click="requestDocumentResize()"
></button>
@@ -86,7 +103,7 @@
<script>
import { mapState, mapGetters, mapMutations, mapActions } from "vuex";
import { RESIZE_DOCUMENT } from "@/definitions/modal-windows";
import { RESIZE_DOCUMENT, EXPORT_IMAGE } from "@/definitions/modal-windows";
import { supportsFullscreen, setToggleButton } from "@/utils/environment-util";
import messages from "./messages.json";
@@ -125,6 +142,9 @@ export default {
...mapActions([
"requestDocumentClose",
]),
requestImageExport() {
this.openModal( EXPORT_IMAGE );
},
requestDocumentResize() {
this.openModal( RESIZE_DOCUMENT );
},

View File

@@ -42,7 +42,7 @@
</template>
<script>
import { mapState, mapGetters, mapActions } from "vuex";
import { mapState, mapGetters, mapMutations, mapActions } from "vuex";
import ZoomableCanvas from "@/components/ui/zcanvas/zoomable-canvas";
import DrawableLayer from "@/components/ui/zcanvas/drawable-layer";
import { scaleToRatio } from "@/utils/image-math";
@@ -52,7 +52,7 @@ import {
/* internal non-reactive properties */
let lastDocument, zCanvas, drawableLayer;
let lastDocument, drawableLayer;
// scale of the on-screen canvas relative to the document
let xScale = 1, yScale = 1, zoom = 1, containerSize;
@@ -63,6 +63,7 @@ export default {
}),
computed: {
...mapState([
"zCanvas",
"windowSize"
]),
...mapGetters([
@@ -80,16 +81,16 @@ export default {
deep: true,
handler( document, oldValue = null ) {
if ( !document?.layers ) {
if ( zCanvas ) {
zCanvas.dispose();
zCanvas = null;
if ( this.zCanvas ) {
this.zCanvas.dispose();
this.setZCanvas( null );
}
return;
}
if ( !zCanvas ) {
if ( !this.zCanvas ) {
this.createCanvas();
this.$nextTick(() => {
zCanvas.insertInPage( this.$refs.canvasContainer );
this.zCanvas.insertInPage( this.$refs.canvasContainer );
this.cacheContainerSize();
this.scaleCanvas();
});
@@ -99,7 +100,7 @@ export default {
lastDocument = name;
flushCache(); // switching between documents
}
if ( zCanvas.width !== width || zCanvas.height !== height ) {
if ( this.zCanvas.width !== width || this.zCanvas.height !== height ) {
this.scaleCanvas();
}
document.layers.forEach( layer => {
@@ -108,7 +109,7 @@ export default {
return;
}
layer.graphics.forEach( graphic => {
const sprite = createSpriteForGraphic( zCanvas, graphic );
const sprite = createSpriteForGraphic( this.zCanvas, graphic );
});
});
},
@@ -124,7 +125,7 @@ export default {
case "brush":
if ( !drawableLayer ) {
drawableLayer = new DrawableLayer( this.activeDocument );
zCanvas.addChild( drawableLayer );
this.zCanvas.addChild( drawableLayer );
}
break;
}
@@ -153,18 +154,21 @@ export default {
this.cacheContainerSize();
},
methods: {
...mapMutations([
"setZCanvas",
]),
...mapActions([
"requestDocumentClose",
]),
createCanvas() {
zCanvas = new ZoomableCanvas({
this.setZCanvas( new ZoomableCanvas({
width: 160,
height: 90,
animate: false,
smoothing: true,
backgroundColor: "red",
stretchToFit: false
});
}));
},
cacheContainerSize() {
containerSize = this.$el.parentNode?.getBoundingClientRect();
@@ -180,12 +184,12 @@ export default {
let { width, height } = this.activeDocument;
({ width, height } = scaleToRatio( width, height, containerSize.width, containerSize.height ));
this.wrapperHeight = `${window.innerHeight - containerSize.top - 20}px`;
zCanvas.setDimensions( width * zoom, height * zoom, true, true ); // replace to not multiply by zoom
this.zCanvas.setDimensions( width * zoom, height * zoom, true, true ); // replace to not multiply by zoom
xScale = width / this.activeDocument.width;
yScale = height / this.activeDocument.height;
zCanvas.setZoomFactor( xScale * zoom, yScale * zoom ); // replace with zCanvas.setZoom()
this.zCanvas.setZoomFactor( xScale * zoom, yScale * zoom ); // replace with zCanvas.setZoom()
this.centerCanvas = zCanvas.getWidth() < containerSize.width || zCanvas.getHeight() < containerSize.height ;
this.centerCanvas = this.zCanvas.getWidth() < containerSize.width || this.zCanvas.getHeight() < containerSize.height ;
},
},
};

View File

@@ -67,9 +67,8 @@ import ImageToDocumentManager from "@/mixins/image-to-document-manag
import { listFolder, downloadFileAsBlob } from "@/services/dropbox-service";
import DropboxImagePreview from "./dropbox-image-preview";
import { truncate } from "@/utils/string-util";
import messages from "./messages.json";
const ACCEPTED_FILE_EXTENSIONS = [ ".jpg", ".jpeg", "gif", "png" ];
import { ACCEPTED_FILE_EXTENSIONS } from "@/definitions/image-types";
import messages from "./messages.json";
function mapEntry( entry, children = [], parent = null ) {
return {
@@ -145,7 +144,7 @@ export default {
return this.leaf.children.filter( entry => {
// only show folders and image files
if ( entry.type === "file" ) {
return ACCEPTED_FILE_EXTENSIONS.some( ext => entry.name.includes( ext ));
return ACCEPTED_FILE_EXTENSIONS.some( ext => entry.name.includes( `.${ext}` ));
}
return true;
});

View File

@@ -1,52 +1,86 @@
/**
* The MIT License (MIT)
*
* Igor Zinken 2019-2020 - https://www.igorski.nl
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
<template>
<div class="resize-image-modal">
<h2 v-t="'resizeDocument'"></h2>
<div class="content form">
<div class="wrapper input">
<label v-t="'width'"></label>
<input
ref="first"
v-model="width"
type="number"
name="width"
/>
</div>
<div class="wrapper input">
<label v-t="'maintainAspectRatio'"></label>
<input
v-model="maintainRatio"
type="checkbox"
name="ratio"
/>
</div>
<div class="wrapper input">
<label v-t="'height'"></label>
<input
v-model="height"
type="number"
name="height"
/>
<modal>
<template #header>
<h2 v-t="'resizeDocument'"></h2>
</template>
<template #content>
<div class="form">
<div class="wrapper input">
<label v-t="'width'"></label>
<input
ref="first"
v-model="width"
type="number"
name="width"
/>
</div>
<div class="wrapper input">
<label v-t="'maintainAspectRatio'"></label>
<input
v-model="maintainRatio"
type="checkbox"
name="ratio"
/>
</div>
<div class="wrapper input">
<label v-t="'height'"></label>
<input
v-model="height"
type="number"
name="height"
/>
</div>
</div>
</template>
<template #actions>
<button
v-t="'save'"
type="button"
class="button"
@click="save()"
></button>
<button
v-t="'cancel'"
type="button"
class="button"
@click="close()"
></button>
</div>
</div>
</template>
</modal>
</template>
<script>
import { mapGetters, mapMutations } from "vuex";
import Modal from "@/components/modal/modal";
import messages from "./messages.json";
export default {
i18n: { messages },
components: {
Modal,
},
data: () => ({
width: 0,
height: 0,
@@ -101,26 +135,3 @@ export default {
}
};
</script>
<style lang="scss" scoped>
@import "@/styles/component";
.resize-image-modal {
@include overlay();
@include component();
h2 {
color: #FFF;
}
@include large() {
$width: 480px;
$height: 300px;
width: $width;
height: $height;
left: calc(50% - #{$width / 2});
top: calc(50% - #{$height / 2});
}
}
</style>

View File

@@ -0,0 +1,131 @@
/**
* The MIT License (MIT)
*
* Igor Zinken 2019-2020 - https://www.igorski.nl
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
<template>
<modal>
<template #header>
<h2 v-t="'exportImage'"></h2>
</template>
<template #content>
<div class="form">
<div class="wrapper input">
<label v-t="'imageType'"></label>
<select-box :options="fileTypes"
v-model="type"
/>
</div>
<div
v-if="hasQualityOptions"
class="wrapper input"
>
<label v-t="'imageQuality'"></label>
<slider
v-model="quality"
:min="0"
:max="100"
/>
</div>
<div class="wrapper input">
<label v-t="'fileName'"></label>
<input
type="text"
v-model="name"
/>
</div>
</div>
</template>
<template #actions>
<button
v-t="'export'"
type="button"
class="button"
@click="exportImage()"
></button>
<button
v-t="'cancel'"
type="button"
class="button"
@click="close()"
></button>
</template>
</modal>
</template>
<script>
import { mapState, mapGetters } from "vuex";
import Modal from "@/components/modal/modal";
import SelectBox from '@/components/ui/select-box/select-box';
import Slider from "@/components/ui/slider/slider";
import { mapSelectOptions } from "@/utils/search-select-util";
import { ACCEPTED_FILE_TYPES, typeToExt, isCompressableFileType } from "@/definitions/image-types";
import { createCanvas } from "@/utils/canvas-util";
import { saveBlobAsFile } from "@/utils/file-util";
import messages from "./messages.json";
export default {
i18n: { messages },
components: {
Modal,
SelectBox,
Slider,
},
data: () => ({
name: "",
type: ACCEPTED_FILE_TYPES[ 0 ],
quality: 95,
}),
computed: {
...mapState([
"zCanvas",
]),
...mapGetters([
"activeDocument",
]),
fileTypes() {
return mapSelectOptions( ACCEPTED_FILE_TYPES );
},
hasQualityOptions() {
return isCompressableFileType( this.type );
},
},
created() {
this.name = this.activeDocument.name.split( "." )[ 0 ];
},
methods: {
async exportImage() {
// TODO: here we clone the current zCanvas state to a new canvas
// of the document dimensions. This will result in a loss of quality if
// the zCanvas is zoomed out. We need to re-render all document properties
// into the new canvas at its own scale.
const { cvs, ctx } = createCanvas( this.activeDocument.width, this.activeDocument.height );
ctx.drawImage( this.zCanvas.getElement(), 0, 0, cvs.width, cvs.height );
const base64 = await fetch( cvs.toDataURL( this.type, this.quality / 100 ));
const blob = await base64.blob();
saveBlobAsFile( blob, `${this.name}.${typeToExt(this.type)}` );
this.close();
},
close() {
this.$emit( "close" );
},
},
};
</script>

View File

@@ -0,0 +1,10 @@
{
"en-US": {
"exportImage": "Export image",
"imageType": "Image type",
"imageQuality": "Image quality",
"fileName": "File name",
"export": "Export",
"cancel": "Cancel"
}
}

View File

@@ -0,0 +1,76 @@
/**
* The MIT License (MIT)
*
* Igor Zinken 2019-2020 - https://www.igorski.nl
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
<template>
<div class="modal">
<slot name="header" />
<div class="content">
<slot name="content" />
<div class="actions">
<slot name="actions" />
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
@import "@/styles/component";
.modal {
@include overlay();
@include component();
h2 {
color: #FFF;
}
.content {
position: relative;
}
.actions {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
text-align: center;
display: flex;
padding: $spacing-small $spacing-medium;
box-sizing: border-box;
button {
flex: 1;
margin: $spacing-small;
}
}
@include large() {
$width: 480px;
$height: 300px;
width: $width;
height: $height;
left: calc(50% - #{$width / 2});
top: calc(50% - #{$height / 2});
}
}
</style>

View File

@@ -0,0 +1,43 @@
/**
* The MIT License (MIT)
*
* Igor Zinken 2019-2020 - https://www.igorski.nl
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
const JPEG = "image/jpg";
const PNG = "image/png";
const GIF = "image/gif";
export const ACCEPTED_FILE_TYPES = [ JPEG, PNG, GIF ];
export const ACCEPTED_FILE_EXTENSIONS = [ "jpg", "jpeg", "png", "gif" ];
export const isCompressableFileType = type => type === JPEG;
export const typeToExt = type => {
switch ( type ) {
default:
throw new Error( `Unsupported type ${type} provided` );
case JPEG:
return ACCEPTED_FILE_EXTENSIONS[ 0 ];
case PNG:
return ACCEPTED_FILE_EXTENSIONS[ 2 ];
case GIF:
return ACCEPTED_FILE_EXTENSIONS[ 3 ];
}
};

View File

@@ -21,4 +21,5 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
export const RESIZE_DOCUMENT = 1;
export const DROPBOX_FILE_SELECTOR = 2;
export const EXPORT_IMAGE = 2;
export const DROPBOX_FILE_SELECTOR = 3;

View File

@@ -75,7 +75,7 @@ import { isMobile } from "@/utils/environment-util";
import store from "./store";
import messages from "./messages.json";
import {
RESIZE_DOCUMENT, DROPBOX_FILE_SELECTOR
RESIZE_DOCUMENT, EXPORT_IMAGE, DROPBOX_FILE_SELECTOR
} from "@/definitions/modal-windows";
Vue.use( Vuex );
@@ -115,6 +115,8 @@ export default {
return null;
case RESIZE_DOCUMENT:
return () => import( "@/components/edit-menu/resize-document/resize-document" );
case EXPORT_IMAGE:
return () => import( "@/components/file-menu/export-image/export-image" );
case DROPBOX_FILE_SELECTOR:
return () => import( "@/components/dropbox-file-selector/dropbox-file-selector" );
}

View File

@@ -20,10 +20,14 @@ export default {
toolboxOpened: false,
optionsPanelOpened: true,
blindActive: false,
dialog: null,
modal: null,
notifications: [],
windowSize: { width: window.innerWidth, height: window.innerHeight },
dialog: null, // currently opened dialog
modal: null, // currently opened modal
notifications: [], // notification message queue
zCanvas: null, // zCanvas instance
windowSize: {
width: window.innerWidth,
height: window.innerHeight
},
},
getters: {
// eslint-disable-next-line no-unused-vars
@@ -71,6 +75,9 @@ export default {
clearNotifications( state ) {
state.notifications = [];
},
setZCanvas( state, zCanvas ) {
state.zCanvas = zCanvas;
},
/**
* cache the resize in the store so components can react to these values
* instead of maintaining multiple listeners at the expense of DOM trashing/performance hits

View File

@@ -3,11 +3,23 @@
.form {
.wrapper {
margin: $spacing-small 0;
label,
input, textarea, .select {
input,
textarea,
.select,
.vue-slider {
display: inline-block;
width: 50%;
width: 50% !important;
}
input, textarea {
box-sizing: border-box;
border-radius: $spacing-small;
border: none;
padding: #{$spacing-small + $spacing-xsmall} $spacing-medium;
}
input[type='checkbox'] {
width: auto;
}
}
}

40
src/utils/file-util.js Normal file
View File

@@ -0,0 +1,40 @@
/**
* The MIT License (MIT)
*
* Igor Zinken 2019-2020 - https://www.igorski.nl
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
const { URL } = window;
export const saveBlobAsFile = ( blob, fileName ) => {
const blobURL = URL.createObjectURL( blob );
const anchor = document.createElement( "a" );
anchor.style.display = "none";
anchor.href = blobURL;
anchor.setAttribute( "download", fileName );
// Safari has no download attribute
if ( typeof anchor.download === "undefined" ) {
anchor.setAttribute( "target", "_blank" );
}
document.body.appendChild( anchor );
anchor.click();
document.body.removeChild( anchor );
URL.revokeObjectURL( blobURL );
};

View File

@@ -111,6 +111,13 @@ describe( "Vuex store", () => {
});
});
it( "should be able to set the current zCanvas instance", () => {
const state = { zCanvas: null };
const canvas = { foo: "bar" };
mutations.setZCanvas( state, canvas );
expect( state.zCanvas ).toEqual( canvas );
});
it( "should be able to set the window size", () => {
const state = { windowSize: { width: 0, height: 0 }};
const width = 500;