Add reusable image editor modal (#503)

* Install cropperjs

* Add cropper js styles

* Add editing image string

* Track inner active state

* Add temp edit button

* Start on image editor modal

* Add custom icons for image manipulation

* Add image manipulation strings

* Tweak cropper styles

* Remove unused import

* Save as blob

* Expose getItem method for manual refreshes

* Add cache-busting to file preview

* Use new API post endpoint, emit refresh event on success

* Add a cache buster to the image editor
This commit is contained in:
Rijk van Zanten
2020-04-29 16:44:34 -04:00
committed by GitHub
parent df1987dba9
commit 4b7fcb79fa
14 changed files with 751 additions and 11 deletions

View File

@@ -25,6 +25,7 @@
"@vue/composition-api": "^0.5.0",
"axios": "^0.19.2",
"base-64": "^0.1.0",
"cropperjs": "^1.5.6",
"date-fns": "^2.12.0",
"lodash": "^4.17.15",
"marked": "^1.0.0",

View File

@@ -0,0 +1,18 @@
<template functional>
<svg
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
fill-rule="evenodd"
clip-rule="evenodd"
stroke-linejoin="round"
stroke-miterlimit="2"
>
<path
d="M 3 2 L 3 11 L 21 11 L 3 2 z M 5 5.2363281 L 12.527344 9 L 5 9 L 5 5.2363281 z M 3 13 L 3 22 L 21 13 L 3 13 z"
></path>
</svg>
</template>
<script lang="ts">
export default {};
</script>

View File

@@ -0,0 +1,19 @@
<template functional>
<svg
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
fill-rule="evenodd"
clip-rule="evenodd"
stroke-linejoin="round"
stroke-miterlimit="2"
>
<path
d="M3,2 L3,11 L21,11 L3,2 Z M5,5.2363281 L12.527344,9 L5,9 L5,5.2363281 Z M3,13 L3,22 L21,13 L3,13 Z"
transform="translate(12.000000, 12.000000) rotate(-90.000000) translate(-12.000000, -12.000000) "
></path>
</svg>
</template>
<script lang="ts">
export default {};
</script>

View File

@@ -25,6 +25,8 @@ import CustomIconGrid6 from './custom-icons/grid_6.vue';
import CustomIconSignalWifi1Bar from './custom-icons/signal_wifi_1_bar.vue';
import CustomIconSignalWifi2Bar from './custom-icons/signal_wifi_2_bar.vue';
import CustomIconSignalWifi3Bar from './custom-icons/signal_wifi_3_bar.vue';
import CustomIconFlipHorizontal from './custom-icons/flip_horizontal.vue';
import CustomIconFlipVertical from './custom-icons/flip_vertical.vue';
const customIcons: string[] = [
'box',
@@ -37,6 +39,8 @@ const customIcons: string[] = [
'signal_wifi_1_bar',
'signal_wifi_2_bar',
'signal_wifi_3_bar',
'flip_horizontal',
'flip_vertical',
];
export default defineComponent({
@@ -51,6 +55,8 @@ export default defineComponent({
CustomIconSignalWifi1Bar,
CustomIconSignalWifi2Bar,
CustomIconSignalWifi3Bar,
CustomIconFlipHorizontal,
CustomIconFlipVertical,
},
props: {
name: {

View File

@@ -1,5 +1,5 @@
<template>
<v-dialog :active="active" @toggle="$emit('toggle', $event)" :persistent="persistent">
<v-dialog v-model="_active" :persistent="persistent">
<template #activator="{ on }">
<slot name="activator" v-bind="{ on }" />
</template>
@@ -12,7 +12,7 @@
<div class="spacer" />
<v-icon name="" />
</header>
<div class="content">
<div class="content" :class="{ 'no-padding': noPadding }">
<v-overlay
v-if="$slots.sidebar"
absolute
@@ -32,14 +32,14 @@
</main>
</div>
<footer class="footer" v-if="$slots.footer || $scopedSlots.footer">
<slot name="footer" v-bind="{ close: () => $emit('toggle', false) }" />
<slot name="footer" v-bind="{ close: () => (_active = false) }" />
</footer>
</article>
</v-dialog>
</template>
<script lang="ts">
import { defineComponent, ref } from '@vue/composition-api';
import { defineComponent, ref, computed } from '@vue/composition-api';
export default defineComponent({
model: {
@@ -57,17 +57,33 @@ export default defineComponent({
},
active: {
type: Boolean,
default: true,
default: undefined,
},
persistent: {
type: Boolean,
default: false,
},
noPadding: {
type: Boolean,
default: false,
},
},
setup() {
setup(props, { emit }) {
const sidebarActive = ref(false);
return { sidebarActive };
const localActive = ref(false);
const _active = computed({
get() {
return props.active === undefined ? localActive.value : props.active;
},
set(newActive: boolean) {
localActive.value = newActive;
emit('toggle', newActive);
},
});
return { sidebarActive, _active };
},
});
</script>
@@ -165,6 +181,10 @@ export default defineComponent({
padding: 32px;
}
}
&.no-padding .main {
padding: 0px;
}
}
.footer {

View File

@@ -44,6 +44,7 @@ export function useItem(collection: Ref<string>, primaryKey: Ref<string | number
softDeleting,
saveAsCopy,
isBatch,
getItem,
};
async function getItem() {

View File

@@ -25,8 +25,6 @@ import 'tinymce/plugins/directionality/plugin';
import Editor from '@tinymce/tinymce-vue';
import { debounce } from 'lodash';
import getEditorStyles from './get-editor-styles';
type CustomFormat = {

View File

@@ -205,6 +205,14 @@
"back": "Back",
"editing_image": "Editing Image...",
"square": "Square",
"free": "Free",
"flip_horizontal": "Flip Horizontal",
"flip_vertical": "Flip Vertical",
"aspect_ratio": "Aspect Ratio",
"rotate": "Rotate",
"project_info": "Project Information",
"database_connection": "Database Connection",

View File

@@ -65,13 +65,23 @@
<div class="file-detail">
<file-preview
v-if="isBatch === false && item && item.data"
:src="item.data.full_url"
:src="`${item.data.full_url}?cache-buster=${cacheBuster}`"
:mime="item.type"
:width="item.width"
:height="item.height"
:title="item.title"
/>
<image-editor
v-if="item && item.type.startsWith('image')"
:id="item.id"
@refresh="changeCacheBuster"
>
<template #activator="{ on }">
<v-button @click="on">Edit</v-button>
</template>
</image-editor>
<v-form
:loading="loading"
:initial-values="item"
@@ -101,6 +111,8 @@ import ActivityDrawerDetail from '@/views/private/components/activity-drawer-det
import useItem from '@/compositions/use-item';
import SaveOptions from '@/views/private/components/save-options';
import FilePreview from './components/file-preview.vue';
import ImageEditor from '@/views/private/components/image-editor';
import { nanoid } from 'nanoid';
type Values = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -109,7 +121,7 @@ type Values = {
export default defineComponent({
name: 'files-detail',
components: { FilesNavigation, ActivityDrawerDetail, SaveOptions, FilePreview },
components: { FilesNavigation, ActivityDrawerDetail, SaveOptions, FilePreview, ImageEditor },
props: {
primaryKey: {
type: String,
@@ -140,6 +152,8 @@ export default defineComponent({
const confirmDelete = ref(false);
const cacheBuster = ref(nanoid());
return {
item,
loading,
@@ -157,8 +171,14 @@ export default defineComponent({
saveAndAddNew,
saveAsCopyAndNavigate,
isBatch,
changeCacheBuster,
cacheBuster,
};
function changeCacheBuster() {
cacheBuster.value = nanoid();
}
function useBreadcrumb() {
const breadcrumb = computed(() => [
{

View File

@@ -0,0 +1,273 @@
.cropper-container {
position: relative;
font-size: 0;
line-height: 0;
direction: ltr;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
-ms-touch-action: none;
touch-action: none;
}
.cropper-container img {
display: block;
width: 100%;
min-width: 0 !important;
max-width: none !important;
height: 100%;
min-height: 0 !important;
max-height: none !important;
image-orientation: 0deg;
}
.cropper-wrap-box,
.cropper-canvas,
.cropper-drag-box,
.cropper-crop-box,
.cropper-modal {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
.cropper-wrap-box,
.cropper-canvas {
overflow: hidden;
}
.cropper-drag-box {
background-color: #fff;
opacity: 0;
}
.cropper-modal {
background-color: #000;
opacity: 0.5;
}
.cropper-view-box {
display: block;
width: 100%;
height: 100%;
overflow: hidden;
outline: 1px solid var(--primary);
outline-color: var(--primary-50);
}
.cropper-dashed {
position: absolute;
display: block;
border: 0 dashed #fff;
border-style: solid;
box-shadow: 0 0px 0px 1px rgba(0, 0, 0, 0.3);
opacity: 0.4;
}
.cropper-dashed.dashed-h {
top: calc(100% / 3);
left: 0;
width: 100%;
height: calc(100% / 3);
border-top-width: 1px;
border-bottom-width: 1px;
}
.cropper-dashed.dashed-v {
top: 0;
left: calc(100% / 3);
width: calc(100% / 3);
height: 100%;
border-right-width: 1px;
border-left-width: 1px;
}
.cropper-center {
position: absolute;
top: 50%;
left: 50%;
display: block;
width: 0;
height: 0;
opacity: 0.75;
}
.cropper-center::before,
.cropper-center::after {
position: absolute;
display: block;
background-color: var(--background-subdued);
content: ' ';
}
.cropper-center::before {
top: 0;
left: -3px;
width: 7px;
height: 1px;
}
.cropper-center::after {
top: -3px;
left: 0;
width: 1px;
height: 7px;
}
.cropper-face,
.cropper-line,
.cropper-point {
position: absolute;
display: block;
width: 100%;
height: 100%;
opacity: 0.1;
}
.cropper-face {
top: 0;
left: 0;
background-color: var(--foreground-inverted);
}
.cropper-line {
background-color: #000;
opacity: 0.05;
}
.cropper-line.line-e {
top: 0;
right: -3px;
width: 5px;
cursor: ew-resize;
}
.cropper-line.line-n {
top: -3px;
left: 0;
height: 5px;
cursor: ns-resize;
}
.cropper-line.line-w {
top: 0;
left: -3px;
width: 5px;
cursor: ew-resize;
}
.cropper-line.line-s {
bottom: -3px;
left: 0;
height: 5px;
cursor: ns-resize;
}
.cropper-point {
width: 10px;
height: 10px;
background: #fff;
border-radius: 50%;
opacity: 1;
}
.cropper-point.point-e {
top: 50%;
right: -5px;
margin-top: -5px;
cursor: ew-resize;
}
.cropper-point.point-n {
top: -5px;
left: 50%;
margin-left: -5px;
cursor: ns-resize;
}
.cropper-point.point-w {
top: 50%;
left: -5px;
margin-top: -5px;
cursor: ew-resize;
}
.cropper-point.point-s {
bottom: -5px;
left: 50%;
margin-left: -5px;
cursor: s-resize;
}
.cropper-point.point-ne {
top: -5px;
right: -5px;
cursor: nesw-resize;
}
.cropper-point.point-nw {
top: -5px;
left: -5px;
cursor: nwse-resize;
}
.cropper-point.point-sw {
bottom: -5px;
left: -5px;
cursor: nesw-resize;
}
.cropper-point.point-se {
right: -5px;
bottom: -5px;
cursor: nwse-resize;
}
.cropper-point.point-se::before {
position: absolute;
right: -50%;
bottom: -50%;
display: block;
width: 200%;
height: 200%;
background-color: var(--primary);
opacity: 0;
content: ' ';
}
.cropper-invisible {
opacity: 0;
}
.cropper-bg {
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC');
}
.cropper-hide {
position: absolute;
display: block;
width: 0;
height: 0;
}
.cropper-hidden {
display: none !important;
}
.cropper-move {
cursor: move;
}
.cropper-crop {
cursor: crosshair;
}
.cropper-disabled .cropper-drag-box,
.cropper-disabled .cropper-face,
.cropper-disabled .cropper-line,
.cropper-disabled .cropper-point {
cursor: not-allowed;
}

View File

@@ -7,6 +7,7 @@
@import 'themes/light';
@import 'lib/codemirror';
@import 'lib/portal-vue';
@import 'lib/cropperjs';
body.light {
@include light;

View File

@@ -0,0 +1,366 @@
<template>
<v-modal v-model="active" class="modal" :title="$t('editing_image')" persistent no-padding>
<template #activator="activatorBinding">
<slot name="activator" v-bind="activatorBinding" />
</template>
<div class="loader" v-if="loading">
<v-progress-circular indeterminate />
</div>
<v-notice type="error" v-else-if="error">
error
</v-notice>
<div
v-show="imageData && imageData.data.full_url && !loading && !error"
class="editor-container"
>
<div class="editor">
<img
ref="imageElement"
:src="imageURL"
role="presentation"
alt=""
@load="onImageLoad"
/>
</div>
<div class="toolbar">
<v-icon name="rotate_90_degrees_ccw" @click="rotate" v-tooltip.top="$t('rotate')" />
<div class="spacer" />
<v-icon
name="flip_horizontal"
@click="flip('horizontal')"
v-tooltip.top="$t('flip_horizontal')"
/>
<v-icon
name="flip_vertical"
@click="flip('vertical')"
v-tooltip.top="$t('flip_vertical')"
/>
<div class="spacer" />
<v-menu
placement="top"
show-arrow
close-on-content-click
v-tooltip.top="$t('aspect_ratio')"
>
<template #activator="{ toggle }">
<v-icon :name="aspectRatioIcon" @click="toggle" />
</template>
<v-list dense>
<v-list-item @click="aspectRatio = 16 / 9" :active="aspectRatio === 16 / 9">
<v-list-item-icon><v-icon name="crop_16_9" /></v-list-item-icon>
<v-list-item-content>16:9</v-list-item-content>
</v-list-item>
<v-list-item @click="aspectRatio = 3 / 2" :active="aspectRatio === 3 / 2">
<v-list-item-icon><v-icon name="crop_3_2" /></v-list-item-icon>
<v-list-item-content>3:2</v-list-item-content>
</v-list-item>
<v-list-item @click="aspectRatio = 5 / 4" :active="aspectRatio === 5 / 4">
<v-list-item-icon><v-icon name="crop_5_4" /></v-list-item-icon>
<v-list-item-content>5:4</v-list-item-content>
</v-list-item>
<v-list-item @click="aspectRatio = 7 / 5" :active="aspectRatio === 7 / 5">
<v-list-item-icon><v-icon name="crop_7_5" /></v-list-item-icon>
<v-list-item-content>7:5</v-list-item-content>
</v-list-item>
<v-list-item @click="aspectRatio = 1 / 1" :active="aspectRatio === 1 / 1">
<v-list-item-icon><v-icon name="crop_square" /></v-list-item-icon>
<v-list-item-content>{{ $t('square') }}</v-list-item-content>
</v-list-item>
<v-list-item @click="aspectRatio = NaN" :active="aspectRatio === NaN">
<v-list-item-icon><v-icon name="crop_free" /></v-list-item-icon>
<v-list-item-content>{{ $t('free') }}</v-list-item-content>
</v-list-item>
</v-list>
</v-menu>
</div>
</div>
<template #footer="{ close }">
<v-button @click="close" secondary>{{ $t('cancel') }}</v-button>
<v-button @click="save" :loading="saving">{{ $t('save') }}</v-button>
</template>
</v-modal>
</template>
<script lang="ts">
import { defineComponent, ref, watch, computed } from '@vue/composition-api';
import api from '@/api';
import useProjectsStore from '@/stores/projects';
import Cropper from 'cropperjs';
import { nanoid } from 'nanoid';
type Image = {
type: string;
data: {
full_url: string;
};
filesize: number;
filename_download: string;
};
export default defineComponent({
props: {
id: {
type: Number,
required: true,
},
},
setup(props, { emit }) {
const projectsStore = useProjectsStore();
const active = ref(false);
const {
loading,
error,
imageData,
imageElement,
save,
saving,
fetchImage,
onImageLoad,
} = useImage();
const {
cropperInstance,
initCropper,
flip,
rotate,
aspectRatio,
aspectRatioIcon,
} = useCropper();
watch(active, (isActive) => {
if (isActive === true) {
fetchImage();
} else {
if (cropperInstance.value) {
cropperInstance.value.destroy();
}
loading.value = false;
error.value = null;
imageData.value = null;
}
});
const imageURL = computed(() => {
return imageData && imageData.value && imageData.value.data.full_url + '?' + nanoid();
});
return {
active,
loading,
error,
imageData,
imageElement,
save,
onImageLoad,
flip,
rotate,
aspectRatio,
aspectRatioIcon,
saving,
imageURL,
};
function useImage() {
const loading = ref(false);
const error = ref(null);
const imageData = ref<Image>(null);
const saving = ref(false);
const imageElement = ref<HTMLImageElement>(null);
return {
loading,
error,
imageData,
saving,
fetchImage,
imageElement,
save,
onImageLoad,
};
async function fetchImage() {
const { currentProjectKey } = projectsStore.state;
try {
loading.value = true;
const response = await api.get(`/${currentProjectKey}/files/${props.id}`, {
params: {
fields: ['data', 'type', 'filesize', 'filename_download'],
},
});
imageData.value = response.data.data;
} catch (err) {
error.value = err;
} finally {
loading.value = false;
}
}
function save() {
const { currentProjectKey } = projectsStore.state;
saving.value = true;
cropperInstance.value
?.getCroppedCanvas({
imageSmoothingQuality: 'high',
})
.toBlob(async (blob) => {
if (blob === null) {
saving.value = false;
return;
}
const formData = new FormData();
formData.append('file', blob, imageData.value?.filename_download);
try {
await api.post(`/${currentProjectKey}/files/${props.id}`, formData);
emit('refresh');
active.value = false;
} catch (err) {
console.error(err);
} finally {
saving.value = false;
}
}, imageData.value?.type);
}
function onImageLoad() {
initCropper();
}
}
function useCropper() {
const cropperInstance = ref<Cropper>(null);
const localAspectRatio = ref(NaN);
const aspectRatio = computed<number>({
get() {
return localAspectRatio.value;
},
set(newAspectRatio) {
localAspectRatio.value = newAspectRatio;
cropperInstance.value?.setAspectRatio(newAspectRatio);
},
});
const aspectRatioIcon = computed(() => {
switch (aspectRatio.value) {
case 16 / 9:
return 'crop_16_9';
case 3 / 2:
return 'crop_3_2';
case 5 / 4:
return 'crop_5_4';
case 7 / 5:
return 'crop_7_5';
case 1 / 1:
return 'crop_square';
case NaN:
default:
return 'crop_free';
}
});
return { cropperInstance, initCropper, flip, rotate, aspectRatio, aspectRatioIcon };
function initCropper() {
if (imageElement.value === null) return;
if (cropperInstance.value) {
cropperInstance.value.destroy();
}
cropperInstance.value = new Cropper(imageElement.value);
}
function flip(type: 'horizontal' | 'vertical') {
if (type === 'vertical') {
if (cropperInstance.value?.getData().scaleX === -1) {
cropperInstance.value?.scaleX(1);
} else {
cropperInstance.value?.scaleX(-1);
}
}
if (type === 'horizontal') {
if (cropperInstance.value?.getData().scaleY === -1) {
cropperInstance.value?.scaleY(1);
} else {
cropperInstance.value?.scaleY(-1);
}
}
}
function rotate() {
cropperInstance.value?.rotate(-90);
}
}
},
});
</script>
<style lang="scss" scoped>
.modal {
--v-modal-content-padding-small: 0px;
--v-modal-content-padding: 0px;
}
.editor-container {
background-color: var(--background-subdued);
&,
.editor {
width: 100%;
height: 100%;
overflow: hidden;
}
img {
// Cropper JS will handle this
opacity: 0;
}
}
.loader {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.toolbar {
position: absolute;
bottom: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 60px;
color: var(--white);
background-color: rgba(0 0 0 / 75%);
backdrop-filter: blur(10px);
.spacer {
width: 12px;
}
> * {
margin: 0 8px;
}
}
</style>

View File

@@ -0,0 +1,4 @@
import ImageEditor from './image-editor.vue';
export { ImageEditor };
export default ImageEditor;

View File

@@ -4923,6 +4923,11 @@ create-react-context@0.3.0, create-react-context@^0.3.0:
gud "^1.0.0"
warning "^4.0.3"
cropperjs@^1.5.6:
version "1.5.6"
resolved "https://registry.yarnpkg.com/cropperjs/-/cropperjs-1.5.6.tgz#82faf432bec709d828f2f7a96d1179198edaf0e2"
integrity sha512-eAgWf4j7sNJIG329qUHIFi17PSV0VtuWyAu9glZSgu/KlQSrfTQOC2zAz+jHGa5fAB+bJldEnQwvJEaJ8zRf5A==
cross-spawn@6.0.5, cross-spawn@^6.0.0, cross-spawn@^6.0.5:
version "6.0.5"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4"