mirror of
https://github.com/directus/directus.git
synced 2026-02-19 10:14:33 -05:00
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:
366
src/views/private/components/image-editor/image-editor.vue
Normal file
366
src/views/private/components/image-editor/image-editor.vue
Normal 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>
|
||||
4
src/views/private/components/image-editor/index.ts
Normal file
4
src/views/private/components/image-editor/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import ImageEditor from './image-editor.vue';
|
||||
|
||||
export { ImageEditor };
|
||||
export default ImageEditor;
|
||||
Reference in New Issue
Block a user