Refactored export window to show original and preview side by side. Improved performance of preview rendering

This commit is contained in:
Igor Zinken
2025-05-19 16:31:50 +02:00
parent 4d1e1a8fd6
commit dbcf9b92ce
5 changed files with 367 additions and 144 deletions

View File

@@ -0,0 +1,178 @@
/**
* The MIT License (MIT)
*
* Igor Zinken 2020-2025 - 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="document-preview"
:class="{
'document-preview--landscape' : isLandscape,
'document-preview--actual' : isActualSize
}"
>
<div class="document-preview__meta">
<p class="document-preview__meta_title">{{ title }}</p>
<p class="document-preview__meta_size">{{ fileSize }}</p>
</div>
<div
class="document-preview__container"
ref="previewContainer"
@scroll="handleScroll( $event )"
>
<img
:src="blobURL"
class="document-preview__image"
/>
</div>
</div>
</template>
<script lang="ts">
import { blobToResource, disposeResource } from "@/utils/resource-manager";
import { displayAsKb, displayAsMb } from "@/utils/string-util";
export type PreviewScrollPos = {
left: number,
top: number
};
export default {
emits: [ "scroll" ],
props: {
src: {
type: Blob,
required: true,
},
title: {
type: String,
required: true,
},
isActualSize: {
type: Boolean,
default: true,
},
isLandscape: {
type: Boolean,
required: true,
},
},
data: () => ({
blobURL: "",
}),
computed: {
fileSize(): string {
const bytes = this.src.size;
return bytes < 1000000 ? displayAsKb( bytes ) : displayAsMb( bytes );
},
},
watch: {
src: {
immediate: true,
handler( value: Blob ): void {
this.disposeExisting();
this.blobURL = blobToResource( value );
}
},
},
unmounted(): void {
this.disposeExisting();
},
methods: {
handleScroll( e: Event ) :void {
const { scrollLeft, scrollTop } = e.target as HTMLDivElement;
this.$emit( "scroll", { left: scrollLeft, top: scrollTop });
},
setScroll({ left, top }: PreviewScrollPos ): void {
this.$refs.previewContainer.scrollLeft = left;
this.$refs.previewContainer.scrollTop = top;
},
disposeExisting(): void {
if ( !!this.blobURL ) {
disposeResource( this.blobURL );
this.blobURL = "";
}
},
},
};
</script>
<style lang="scss" scoped>
@use "@/styles/_colors";
@use "@/styles/_mixins";
@use "@/styles/_variables";
$metaHeight: 80px;
.document-preview {
overflow: hidden;
@include mixins.componentIdeal( variables.$preview-ideal-width, variables.$preview-ideal-height ) {
height: variables.$preview-max-height;
}
&__meta {
width: 100%;
border: 1px solid colors.$color-lines;
padding: 0 variables.$spacing-medium;
margin-bottom: variables.$spacing-small;
box-sizing: border-box;
max-height: $metaHeight;
&_title {
font-weight: bold;
margin-bottom: 0;
}
&_size {
margin-top: 0;
}
}
&__container {
overflow-x: auto;
overflow-y: auto;
height: calc(100% - $metaHeight);
}
&__image {
width: 100%;
}
&--landscape {
.document-preview__image {
width: auto;
height: 100%;
}
}
&--actual {
.document-preview__container {
overflow-x: auto;
}
.document-preview__image {
width: auto !important;
height: auto !important;
}
}
}
</style>

View File

@@ -1,7 +1,7 @@
/**
* The MIT License (MIT)
*
* Igor Zinken 2020-2024 - https://www.igorski.nl
* Igor Zinken 2020-2025 - 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
@@ -53,10 +53,6 @@
class="input-field"
/>
</div>
<div v-if="fileSize" class="wrapper input">
<label v-t="'fileSize'"></label>
<div class="form-element">{{ fileSize }}</div>
</div>
<template v-if="canCreateAnimatedGIF">
<div class="wrapper input">
<label v-t="'layersToAnimation'"></label>
@@ -108,45 +104,61 @@
</template>
</template>
</div>
<div
v-if="base64preview"
class="preview-wrapper"
:class="{
'preview-wrapper--landscape': isLandscape,
'preview-wrapper--actual' : actualSize
}"
>
<div class="preview-container">
<img
:src="base64preview"
class="preview-image"
/>
</div>
</div>
<document-preview
v-if="showOriginal && previewOriginalBlob"
class="document-preview"
ref="previewOriginal"
:title="$t('original')"
:src="previewOriginalBlob"
:is-actual-size="actualSize"
:is-landscape="isLandscape"
@scroll="syncScroll( false, $event )"
/>
<document-preview
v-if="previewBlob"
class="document-preview"
ref="previewExport"
:title="$t('exported', { type: selectedType, quality: hasQualityOptions ? quality : 100 })"
:src="previewBlob"
:is-actual-size="actualSize"
:is-landscape="isLandscape"
@scroll="syncScroll( true, $event )"
/>
</div>
</template>
<template #actions>
<div class="export-actions">
<button
v-t="'export'"
type="button"
class="button"
:disabled="isLoading"
@click="exportImage()"
></button>
<button
v-t="'cancel'"
type="button"
class="button"
@click="closeModal()"
></button>
</div>
<div v-if="canChooseSize" class="wrapper input actual-size-button">
<label v-t="'viewActualSize'"></label>
<toggle-button
v-model="actualSize"
name="actualSize"
/>
<div class="export-actions__group">
<button
v-t="'export'"
type="button"
class="button"
:disabled="isLoading"
@click="exportImage()"
></button>
<button
v-t="'cancel'"
type="button"
class="button"
@click="closeModal()"
></button>
</div>
<div class="export-actions__group export-options">
<div v-if="showOriginal" class="wrapper input option-button">
<label v-t="'syncScroll'"></label>
<toggle-button
v-model="syncPreviews"
name="syncPreviews"
/>
</div>
<div v-if="canChooseSize" class="wrapper input option-button">
<label v-t="'viewActualSize'"></label>
<toggle-button
v-model="actualSize"
name="actualSize"
/>
</div>
</div>
</div>
</template>
</modal>
@@ -155,27 +167,30 @@
<script lang="ts">
import { type Component, defineAsyncComponent } from "vue";
import { mapState, mapGetters, mapMutations } from "vuex";
import ToggleButton from "@/components/third-party/vue-js-toggle-button/ToggleButton.vue";
import DocumentPreview, { type PreviewScrollPos } from "@/components/document-preview/document-preview.vue"
import Modal from "@/components/modal/modal.vue";
import SelectBox from "@/components/ui/select-box/select-box.vue";
import Slider from "@/components/ui/slider/slider.vue";
import ToggleButton from "@/components/third-party/vue-js-toggle-button/ToggleButton.vue";
import { MAX_SPRITESHEET_WIDTH } from "@/definitions/editor-properties";
import { EXPORTABLE_IMAGE_TYPES, GIF, typeToExt, isCompressableFileType } from "@/definitions/image-types";
import { STORAGE_TYPES } from "@/definitions/storage-types";
import { supportsGIF, createGIF, createAnimatedGIF } from "@/services/gif-creation-service";
import { mapSelectOptions, type SelectOption } from "@/utils/search-select-util";
import { isLandscape, isSquare } from "@/math/image-math";
import { resizeToBase64, getPixelRatio } from "@/utils/canvas-util";
import { canvasToBlob, resizeImage, getPixelRatio } from "@/utils/canvas-util";
import { supportsDropbox, supportsGoogleDrive, supportsS3 } from "@/utils/cloud-service-loader";
import { unblockedWait } from "@/utils/debounce-util";
import { createDocumentSnapshot, createLayerSnapshot, tilesToSingle } from "@/utils/document-util";
import { saveBlobAsFile, base64toBlob } from "@/utils/file-util";
import { displayAsKb } from "@/utils/string-util";
import { base64toBlob, saveBlobAsFile } from "@/utils/file-util";
import messages from "./messages.json";
const SMALL_IMAGE_SIZE_PX = 400;
export default {
i18n: { messages },
components: {
DocumentPreview,
Modal,
SelectBox,
Slider,
@@ -185,7 +200,9 @@ export default {
name: "",
type: EXPORTABLE_IMAGE_TYPES[ 0 ],
quality: 95,
base64preview: null,
previewOriginalBlob: null,
previewBlob: null,
syncPreviews: false,
layersToSpriteSheet: false,
layersToAnimatedGIF: false,
frameDurationMs: 100,
@@ -200,11 +217,19 @@ export default {
computed: {
...mapState([
"loadingStates",
"windowSize",
]),
...mapGetters([
"activeDocument",
"isLoading",
]),
showOriginal(): boolean {
// see _variables.scss $preview-ideal-width and $preview-ideal-height
return this.windowSize.width >= 1280 && this.windowSize.height >= 700;
},
selectedType(): string {
return typeToExt( this.type );
},
fileTypes(): SelectOption[] {
const types = this.canCreateGIF ? [ ...EXPORTABLE_IMAGE_TYPES, GIF.mime ] : EXPORTABLE_IMAGE_TYPES;
return mapSelectOptions( types );
@@ -215,12 +240,6 @@ export default {
qualityPercentile(): number {
return parseFloat(( this.quality / 100 ).toFixed( 2 ));
},
fileSize(): string | null {
if ( !this.base64preview ) {
return null;
}
return displayAsKb( Math.round( this.base64preview.length * 6 / 8 ));
},
canCreateSpriteSheet(): boolean {
return this.activeDocument.layers.length > 1 &&
this.activeDocument.width <= MAX_SPRITESHEET_WIDTH &&
@@ -278,10 +297,19 @@ export default {
if ( this.layersToSpriteSheet ) {
this.renderPreview();
}
}
},
syncPreviews( enabled: boolean ): void {
if ( !enabled ) {
return;
}
this.syncScroll( true, { left: this.width / 2, top: this.height / 2 }); // syncing one syncs the other =)
},
},
created(): void {
this.canCreateGIF = supportsGIF();
if ( this.showOriginal ) {
this.syncPreviews = true;
}
this.name = this.activeDocument.name.split( "." )[ 0 ];
[
// changes to any of the export properties should trigger a re-render
@@ -294,7 +322,8 @@ export default {
this.renderPreview();
},
beforeUnmount(): void {
this.base64preview = null;
this.previewOriginalBlob = null;
this.previewBlob = null;
this.snapshots = null;
this.snapshot = null;
},
@@ -307,20 +336,7 @@ export default {
async exportImage(): Promise<void> {
this.setLoading( "exp" );
// zCanvas magnifies content by the pixel ratio for a crisper result, downscale
// to actual dimensions of the document
const { width, height } = this.activeDocument;
const resizedImage = this.layersToSpriteSheet ? this.base64preview : await resizeToBase64(
this.base64preview,
width,
height,
this.type,
this.qualityPercentile,
width * getPixelRatio(),
height * getPixelRatio()
);
const file: Blob = await base64toBlob( resizedImage );;
const file = this.previewBlob;
const fileName = `${this.name}.${typeToExt(this.type)}`;
switch ( this.storageLocation ) {
@@ -355,41 +371,51 @@ export default {
this.setLoading( LOADING_KEY );
const { width, height } = this.activeDocument;
// created merged layer export and scale it to original (pixel ratio agnostic) dimensions
if ( !this.snapshot ) {
this.snapshot = await resizeImage(
await createDocumentSnapshot( this.activeDocument ),
width, height, 0, 0, width * getPixelRatio(), height * getPixelRatio()
);
this.previewOriginalBlob = await canvasToBlob( this.snapshot );
}
let snapshotCvs;
const multiLayerExport = ( this.type === GIF.mime && this.layersToAnimatedGIF ) || this.layersToSpriteSheet;
if ( multiLayerExport ) {
// if we are going to work with individual layers, lazily create a list of layer snapshots
if ( !this.snapshots ) {
this.snapshots = await Promise.all( [ ...this.activeDocument.layers ].reverse().map( layer => {
return createLayerSnapshot( layer, this.activeDocument );
this.snapshots = await Promise.all( [ ...this.activeDocument.layers ].reverse().map( async layer => {
return resizeImage(
await createLayerSnapshot( layer, this.activeDocument ),
width, height, 0, 0, width * getPixelRatio(), height * getPixelRatio()
);
}));
}
if ( this.layersToAnimatedGIF ) {
this.base64preview = await createAnimatedGIF( this.snapshots, this.frameDurationMs / 100 );
this.previewBlob = await base64toBlob(
await createAnimatedGIF( this.snapshots, this.frameDurationMs / 100 )
);
}
else if ( this.layersToSpriteSheet ) {
snapshotCvs = tilesToSingle( this.snapshots, width, height, parseFloat( this.sheetCols || "4" ));
}
} else {
// merged layer export
if ( !this.snapshot ) {
this.snapshot = await createDocumentSnapshot( this.activeDocument );
}
snapshotCvs = this.snapshot;
}
if ( snapshotCvs ) {
this.width = snapshotCvs.width;
this.height = snapshotCvs.height;
this.base64preview = await this.canvasToImageFormat( snapshotCvs );
this.previewBlob = await this.snapshotToImageFormat( snapshotCvs );
} else {
this.width = width;
this.height = height;
}
// force actual size preview for small images
if ( !this.actualSize && this.width < 400 && this.height < 400 ) {
if ( !this.actualSize && this.width < SMALL_IMAGE_SIZE_PX && this.height < SMALL_IMAGE_SIZE_PX ) {
this.actualSize = true;
this.canChooseSize = false;
}
@@ -401,36 +427,80 @@ export default {
this.renderPreview();
}
},
async canvasToImageFormat( canvas: HTMLCanvasElement ): Promise<string> {
async snapshotToImageFormat( canvas: HTMLCanvasElement ): Promise<Blob> {
let base64: string;
if ( this.type === GIF.mime ) {
const base64gif = await createGIF( canvas );
return base64gif;
base64 = await createGIF( canvas );
} else {
base64 = canvas.toDataURL( this.type, this.qualityPercentile );
}
return await base64toBlob( base64 );
},
syncScroll( isExport: boolean, scrollPos: PreviewScrollPos ): void {
if ( !this.syncPreviews ) {
return;
}
if ( isExport ) {
this.$refs.previewOriginal?.setScroll( scrollPos );
} else {
this.$refs.previewExport.setScroll( scrollPos );
}
return canvas.toDataURL( this.type, this.qualityPercentile );
},
},
};
</script>
<style lang="scss" scoped>
@use "sass:math";
@use "@/styles/_colors";
@use "@/styles/_mixins";
@use "@/styles/_variables";
@use "@/styles/component";
@use "@/styles/typography";
@use "@/styles/ui";
.export-modal {
$idealWidth: 990px;
$idealHeight: 500px;
$maxPreviewHeight: 445px;
$idealFormWidth: 340px;
@include ui.modalBase( $idealWidth, $idealHeight );
.export-modal {
width: 100%;
@include ui.modalBase( variables.$ideal-width, variables.$preview-ideal-height );
.export-form {
width: $idealFormWidth;
max-width: $idealFormWidth;
}
.export-actions {
display: flex;
width: 100%;
justify-content: space-between;
align-items: center;
}
@include mixins.componentIdeal( $idealWidth, $idealHeight ) {
@include mixins.large() {
.export-actions button {
width: math.div( $idealFormWidth, 2 );
}
}
@include mixins.mobile() {
.export-form {
width: calc(100% - 16pt);
max-width: unset;
}
.document-preview {
margin: variables.$spacing-medium 0 0 !important;
}
.export-ui {
display: initial !important;
}
}
@include mixins.componentIdeal( variables.$preview-ideal-width, variables.$preview-ideal-height ) {
width: variables.$preview-ideal-width;
position: relative;
.export-ui {
@@ -438,79 +508,44 @@ export default {
justify-content: space-between;
}
.export-form,
.export-actions {
flex: 0.4;
margin-right: variables.$spacing-xlarge;
.document-preview {
width: 420px;
}
.preview-wrapper {
flex: 0.6;
height: $maxPreviewHeight;
overflow-x: hidden; // image is always full width
overflow-y: scroll;
.export-actions__group {
display: flex;
}
.export-actions {
button {
flex: 0.5;
}
}
.actual-size-button {
margin-left: auto;
margin-top: variables.$spacing-medium;
.option-button {
label {
margin-right: variables.$spacing-small;
}
}
}
@include mixins.componentFallback( $idealWidth, $idealHeight ) {
.preview-container {
height: $maxPreviewHeight !important;
@include mixins.componentFallback( variables.$preview-ideal-width, variables.$preview-ideal-height ) {
// this view does not fit the original and export preview side-by-side, only show export
.export-ui {
display: flex;
}
.preview-image {
margin: variables.$spacing-medium 0;
.document-preview {
flex: 1;
margin-left: variables.$spacing-medium;
height: variables.$preview-max-height !important;
}
.export-actions {
.export-actions__group {
display: flex;
width: 100%;
}
.actual-size-button {
.export-options {
display: none;
}
}
.preview-wrapper {
.preview-image {
width: 100%;
}
&--landscape {
overflow-y: hidden;
overflow-x: auto;
.preview-container {
height: 100%;
}
.preview-image {
width: auto;
height: 100%;
}
}
&--actual {
overflow-x: auto;
overflow-y: auto;
.preview-image {
width: auto !important;
height: auto !important;
}
}
}
}
.expl {

View File

@@ -4,7 +4,9 @@
"imageType": "Image type",
"imageQuality": "Image quality",
"fileName": "File name",
"fileSize": "File size",
"original": "Original",
"exported": "{type} quality: {quality}%",
"syncScroll": "Sync scroll",
"layersToAnimation": "As animation",
"frameDuration": "Frame duration (ms)",
"layersToSheetExpl": "This document contains layers and seems to be of appropriate size. You can combine the layers into a grid layout (e.g. create a spritesheet)",

View File

@@ -5,6 +5,12 @@ $ideal-width: 990px;
$menu-height: 46px;
$heading-height: 40px;
/* document preview variables */
$preview-ideal-width: 1280px;
$preview-ideal-height: 700px;
$preview-max-height: $preview-ideal-height - 55px;
/* spacing units used for global margins / paddings */
$spacing-xxsmall: 2px;

View File

@@ -1,7 +1,7 @@
/**
* The MIT License (MIT)
*
* Igor Zinken 2020-2023 - https://www.igorski.nl
* Igor Zinken 2020-2025 - 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
@@ -25,4 +25,6 @@ export const truncate = ( string = "", maxLength = 100 ): string =>
export const displayAsKb = ( size: number ): string => `${( size / 1024 ).toFixed( 2 )} Kb`;
export const displayAsMb = ( size: number ): string => `${( size / ( 1024 * 1024 )).toFixed( 2 )} Mb`;
export const formatFileName = ( fileName: string ) => fileName.split( " " ).join ( "_" );