mirror of
https://github.com/directus/directus.git
synced 2026-01-27 07:08:17 -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:
@@ -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",
|
||||
|
||||
18
src/components/v-icon/custom-icons/flip_horizontal.vue
Normal file
18
src/components/v-icon/custom-icons/flip_horizontal.vue
Normal 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>
|
||||
19
src/components/v-icon/custom-icons/flip_vertical.vue
Normal file
19
src/components/v-icon/custom-icons/flip_vertical.vue
Normal 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>
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -44,6 +44,7 @@ export function useItem(collection: Ref<string>, primaryKey: Ref<string | number
|
||||
softDeleting,
|
||||
saveAsCopy,
|
||||
isBatch,
|
||||
getItem,
|
||||
};
|
||||
|
||||
async function getItem() {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(() => [
|
||||
{
|
||||
|
||||
273
src/styles/lib/_cropperjs.scss
Normal file
273
src/styles/lib/_cropperjs.scss
Normal 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;
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
@import 'themes/light';
|
||||
@import 'lib/codemirror';
|
||||
@import 'lib/portal-vue';
|
||||
@import 'lib/cropperjs';
|
||||
|
||||
body.light {
|
||||
@include light;
|
||||
|
||||
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;
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user