From 100e1fa33b113da931b981f4721608e869f92bfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kv=C3=A1=C4=8Dek?= Date: Fri, 27 Oct 2023 22:30:47 +0200 Subject: [PATCH] Pridan fotoeditor --- package.json | 1 + src/components/flights/FlightTrackMap.vue | 2 +- src/components/map/MapWithMarkers.vue | 3 - src/components/photos/PhotosList.vue | 16 +- src/composables/objects/flight.js | 1 - src/composables/photos/editor/crop.js | 52 ++++ src/composables/photos/editor/photo.js | 16 ++ src/composables/photos/editor/preview.js | 18 ++ src/composables/photos/editor/splitter.js | 10 + src/composables/utils.js | 4 + src/graphql/fragments/photo.js | 2 + src/graphql/mutations/photo.js | 40 ++- src/graphql/queries/photo.js | 30 +++ src/pages/profile/flight/EditFlightPage.vue | 4 +- src/pages/profile/flight/PhotoEditorPage.vue | 267 +++++++++++++++++++ src/router/routes.js | 2 + src/stores/Photo.js | 25 +- yarn.lock | 32 +++ 18 files changed, 502 insertions(+), 23 deletions(-) create mode 100644 src/composables/photos/editor/crop.js create mode 100644 src/composables/photos/editor/photo.js create mode 100644 src/composables/photos/editor/preview.js create mode 100644 src/composables/photos/editor/splitter.js create mode 100644 src/composables/utils.js create mode 100644 src/graphql/queries/photo.js create mode 100644 src/pages/profile/flight/PhotoEditorPage.vue diff --git a/package.json b/package.json index 1337e3b..b66c629 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "vue": "^3.2.47", "vue-chartjs": "^5.2.0", "vue-inner-image-zoom": "^2.0.0", + "vue-picture-cropper": "^0.7.0", "vue-router": "^4.1.6", "vue3-google-map": "^0.15.0" }, diff --git a/src/components/flights/FlightTrackMap.vue b/src/components/flights/FlightTrackMap.vue index e319f4c..54aa9f7 100644 --- a/src/components/flights/FlightTrackMap.vue +++ b/src/components/flights/FlightTrackMap.vue @@ -17,7 +17,7 @@ const poisWithGPS = computed(() => flight.value.track.map(poi => poi.pointOfInte const mapMarkers = [flight.value.takeoffAirport].concat(poisWithGPS.value).concat([flight.value.landingAirport]); const pointsOfInterests = computed(() => mapMarkers - .filter(marker => marker.gpsLatitude && marker.gpsLongiture) + .filter(marker => marker.gpsLatitude && marker.gpsLongitude) .map( marker => ({label: marker.name, position: {lat: marker.gpsLatitude, lng: marker.gpsLongitude}}) )); diff --git a/src/components/map/MapWithMarkers.vue b/src/components/map/MapWithMarkers.vue index bd1a431..b59d9b5 100644 --- a/src/components/map/MapWithMarkers.vue +++ b/src/components/map/MapWithMarkers.vue @@ -27,9 +27,6 @@ const props = defineProps({ }) const track = ref([]); -const speed = ref([]); -const elevation = ref([]); - const bounds = computed(() => { const photosTransformed = props.photos.map(p => ({position: {lat: p.gpsLatitude, lng: p.gpsLongitude}})); const allData = [...props.markers, ...track.value, ...photosTransformed]; diff --git a/src/components/photos/PhotosList.vue b/src/components/photos/PhotosList.vue index 3ec0547..ec1ead9 100644 --- a/src/components/photos/PhotosList.vue +++ b/src/components/photos/PhotosList.vue @@ -73,9 +73,9 @@ const openBatchEdit = () => { emits('batch-edit', photos); } -const batchRotate = (angle) => { +const batchRotate = (direction) => { const photos = selectedPhotos.value; - emits("batch-rotate", {photos, angle}); + emits("batch-rotate", {photos, direction}); } const isBatchEditAllowed = computed(() => Object.values(batchEdit.value).filter(v => v).length > 0); @@ -139,9 +139,9 @@ const batchOptionsProps = computed(() => ({ - - - + + + @@ -151,9 +151,9 @@ const batchOptionsProps = computed(() => ({ - - - + + + diff --git a/src/composables/objects/flight.js b/src/composables/objects/flight.js index d280d7b..278240c 100644 --- a/src/composables/objects/flight.js +++ b/src/composables/objects/flight.js @@ -3,7 +3,6 @@ import {useFlightsStore} from "stores/Flights"; import {ref} from "vue"; import {storeToRefs} from "pinia"; import {confirmObject} from "src/composables/dialog"; -import {useAppStore} from "stores/App"; export const flightName = (flight) => { const date = formatDatetime(flight.takeoffDatetime, FORMAT_DATE); diff --git a/src/composables/photos/editor/crop.js b/src/composables/photos/editor/crop.js new file mode 100644 index 0000000..3d0577b --- /dev/null +++ b/src/composables/photos/editor/crop.js @@ -0,0 +1,52 @@ +import {computed, ref, watch} from "vue"; +import {cropper} from "vue-picture-cropper"; +import {roundWithPrecision} from "src/composables/utils"; +import {photoAspectRatio} from "src/composables/photos/editor/photo"; + +export const cropperImgSizes = computed(() => [cropper.getImageData().width, cropper.getImageData().height]); +export const croppedArea = ref(null); +export const selectedAspectRatio = ref(null); + +watch(() => selectedAspectRatio.value, (newVal) => { + cropper.setAspectRatio(newVal); +}) + +export const setCropData = () => { + const [w, h] = cropperImgSizes.value; + + const cropData = cropper.getCropBoxData(); + croppedArea.value = { + left: roundWithPrecision(cropData.left / w, 5), + top: roundWithPrecision(cropData.top / h, 5), + width: roundWithPrecision(cropData.width / w, 5), + height: roundWithPrecision(cropData.height / h, 5) + }; +} + +export const aspectRatioOptions = computed(() => + [ + {label: '16:9', value: roundWithPrecision(16 / 9, 5)}, + {label: '4:3', value: roundWithPrecision(4 / 3, 5)}, + {label: '9:16', value: roundWithPrecision(9 / 16, 5)}, + {label: '3:4', value: roundWithPrecision(3 / 4, 5)}, + {label: '1:1', value: 1}, + {label: 'Volný', value: null}, + {label: 'Původní', value: roundWithPrecision(photoAspectRatio.value, 5)}, + ] +); + +export const cropperReady = (e) => { + if (croppedArea.value) { + const [w, h] = cropperImgSizes.value; + selectedAspectRatio.value = roundWithPrecision((croppedArea.value.width * w) / (croppedArea.value.height * h), 5); + + cropper.setCropBoxData({ + left: croppedArea.value.left * w, + top: croppedArea.value.top * h, + width: croppedArea.value.width * w, + height: croppedArea.value.height * h + }); + } else { + selectedAspectRatio.value = roundWithPrecision(photoAspectRatio.value, 5); + } +} diff --git a/src/composables/photos/editor/photo.js b/src/composables/photos/editor/photo.js new file mode 100644 index 0000000..e43c29a --- /dev/null +++ b/src/composables/photos/editor/photo.js @@ -0,0 +1,16 @@ +import {computed, ref} from "vue"; +import {usePhotosStore} from "stores/Photo"; + +export const loading = ref(true); +export const photo = ref(null); +export const fetchPhoto = (id) => { + loading.value = true; + return usePhotosStore().fetchDetail(id) + .then(resp => { + photo.value = resp; + return resp; + }) + .finally(() => loading.value = false); +}; + +export const photoAspectRatio = computed(() => photo.value.width / photo.value.height); diff --git a/src/composables/photos/editor/preview.js b/src/composables/photos/editor/preview.js new file mode 100644 index 0000000..f089a94 --- /dev/null +++ b/src/composables/photos/editor/preview.js @@ -0,0 +1,18 @@ +import {computed} from "vue"; +import {croppedArea} from "src/composables/photos/editor/crop"; + +export const getPreviewUrl = (photoId, adjustments) => { + const url = new URL(`${process.env.API_URL}/photo/editor-preview/${photoId}`); + url.search = new URLSearchParams(adjustments).toString(); + return url.toString(); +}; + +export const croppedAreaForPreview = computed(() => { + if (!croppedArea.value) return {}; + return { + crop_left: croppedArea.value.left || 0, + crop_top: croppedArea.value.top || 0, + crop_width: croppedArea.value.width || 1, + crop_height: croppedArea.value.height || 1 + } +}); diff --git a/src/composables/photos/editor/splitter.js b/src/composables/photos/editor/splitter.js new file mode 100644 index 0000000..4df0ae3 --- /dev/null +++ b/src/composables/photos/editor/splitter.js @@ -0,0 +1,10 @@ +import {computed, ref} from "vue"; +import {photoAspectRatio} from "src/composables/photos/editor/photo"; + +export const compare = ref(false); + +export const splitterWidth = ref(750); // TODO: doresit onresize a zmenu velikosti +export const splitter = ref(50); +export const onResize = (info) => { + splitterWidth.value = info.width < 750 ? info.width : 750; +} diff --git a/src/composables/utils.js b/src/composables/utils.js new file mode 100644 index 0000000..3ddbfde --- /dev/null +++ b/src/composables/utils.js @@ -0,0 +1,4 @@ +export const roundWithPrecision = (number, precision) => { + const factor = Math.pow(10, precision); + return Math.round(number * factor) / factor; +} diff --git a/src/graphql/fragments/photo.js b/src/graphql/fragments/photo.js index cdc56e6..fbf2749 100644 --- a/src/graphql/fragments/photo.js +++ b/src/graphql/fragments/photo.js @@ -13,6 +13,8 @@ export const FRAGMENT_PHOTO_FULL_INFO = gql` id name } + width + height isFlightCover gpsLatitude gpsLongitude diff --git a/src/graphql/mutations/photo.js b/src/graphql/mutations/photo.js index 35f9675..4bbd39e 100644 --- a/src/graphql/mutations/photo.js +++ b/src/graphql/mutations/photo.js @@ -1,5 +1,6 @@ import gql from "graphql-tag"; import {FRAGMENT_PHOTO_FULL_INFO} from "src/graphql/fragments/photo"; +import {FRAGMENT_FLIGHT_LIST_INFO} from "src/graphql/fragments/flight_list"; export const UPLOAD_PHOTO = gql` mutation uploadPhoto($input: UploadPhotoInput!) { @@ -21,16 +22,49 @@ mutation editPhoto($id: Int!, $input: EditPhotoInput!) { ${FRAGMENT_PHOTO_FULL_INFO} `; -export const ROTATE_PHOTO = gql` -mutation rotatePhoto($id: Int!, $angle: Int!) { - rotatePhoto(id: $id, angle: $angle) { +export const CHANGE_PHOTO_ORIENTATION = gql` +mutation changeOrientation($id: Int!, $direction: String!) { + changeOrientation(id: $id, direction: $direction) { ...photoFullInfo + + flight { + ...flightListInfo + } } } ${FRAGMENT_PHOTO_FULL_INFO} + ${FRAGMENT_FLIGHT_LIST_INFO} `; +export const ADJUST_PHOTO = gql` +mutation adjustPhoto($id: Int!, $adjustment: AdjustmentInput!) { + adjustPhoto(id: $id, adjustment: $adjustment) { + ...photoFullInfo + + adjustment { + contrast + saturation + brightness + rotate + sharpness + cropLeft + cropTop + cropWidth + cropHeight + } + + flight { + ...flightListInfo + } + } + } + + ${FRAGMENT_PHOTO_FULL_INFO} + ${FRAGMENT_FLIGHT_LIST_INFO} +`; + +// TODO: ^^ udelat z toho asi jeden fragment a pouzit i v query GET_PHOTO export const DELETE_PHOTO = gql` mutation deletePhoto($id: Int!) { diff --git a/src/graphql/queries/photo.js b/src/graphql/queries/photo.js new file mode 100644 index 0000000..df51a9d --- /dev/null +++ b/src/graphql/queries/photo.js @@ -0,0 +1,30 @@ +import gql from "graphql-tag"; +import {FRAGMENT_PHOTO_FULL_INFO} from "src/graphql/fragments/photo"; +import {FRAGMENT_FLIGHT_LIST_INFO} from "src/graphql/fragments/flight_list"; + +export const PHOTO_DETAIL = gql` +query photo($id: Int!) { + photo(id: $id) { + ...photoFullInfo + + adjustment { + contrast + saturation + brightness + rotate + sharpness + cropLeft + cropTop + cropWidth + cropHeight + } + + flight { + ...flightListInfo + } + } +} + +${FRAGMENT_PHOTO_FULL_INFO} +${FRAGMENT_FLIGHT_LIST_INFO} +`; diff --git a/src/pages/profile/flight/EditFlightPage.vue b/src/pages/profile/flight/EditFlightPage.vue index f767e7b..02f0928 100644 --- a/src/pages/profile/flight/EditFlightPage.vue +++ b/src/pages/profile/flight/EditFlightPage.vue @@ -101,10 +101,10 @@ const deletePhoto = (id) => { }); } -const rotatePhotos = ({photos, angle}) => { +const rotatePhotos = ({photos, direction}) => { const promises = []; for (const photo of photos) { - promises.push(usePhotosStore().rotate(photo.id, angle)); + promises.push(usePhotosStore().changeOrientation(photo.id, direction)); } let success = 0; diff --git a/src/pages/profile/flight/PhotoEditorPage.vue b/src/pages/profile/flight/PhotoEditorPage.vue new file mode 100644 index 0000000..4915d1c --- /dev/null +++ b/src/pages/profile/flight/PhotoEditorPage.vue @@ -0,0 +1,267 @@ + + + diff --git a/src/router/routes.js b/src/router/routes.js index d2b0e15..4c90f6b 100644 --- a/src/router/routes.js +++ b/src/router/routes.js @@ -50,6 +50,8 @@ const routes = [ {path: 'letadla/:id', name: "aircraftDetail", component: () => import('pages/profile/aircraft/AircraftDetailPage.vue')}, {path: 'letadla/:id/edit', name: "aircraftEdit", component: () => import('pages/profile/aircraft/EditAircraftPage.vue')}, + {path: 'foto-editor/:id', name: "photoEditor", component: () => import("pages/profile/flight/PhotoEditorPage.vue")}, + {path: 'udalosti', name: "events", component: () => import('pages/profile/event/EventsPage.vue')}, {path: 'udalosti/novy', name: "addEvent", component: () => import('pages/profile/event/NewEventPage.vue')}, {path: 'udalosti/:id', name: "eventDetail", component: () => import('pages/profile/event/EventDetailPage.vue')}, diff --git a/src/stores/Photo.js b/src/stores/Photo.js index d8ddbb6..2519dec 100644 --- a/src/stores/Photo.js +++ b/src/stores/Photo.js @@ -1,6 +1,7 @@ import {defineStore} from "pinia"; import {apolloWrapper} from "src/graphql/apollo"; -import {DELETE_PHOTO, EDIT_PHOTO, ROTATE_PHOTO, UPLOAD_PHOTO} from "src/graphql/mutations/photo"; +import {ADJUST_PHOTO, CHANGE_PHOTO_ORIENTATION, DELETE_PHOTO, EDIT_PHOTO, UPLOAD_PHOTO} from "src/graphql/mutations/photo"; +import {PHOTO_DETAIL} from "src/graphql/queries/photo"; export const usePhotosStore = defineStore('Photos', { state: () => ({ @@ -8,6 +9,11 @@ export const usePhotosStore = defineStore('Photos', { }), actions: { + fetchDetail(id) { + return apolloWrapper.query(PHOTO_DETAIL, {id}).then(({data}) => { + return data.photo; + }); + }, upload(flightId, formData) { if (!flightId) { console.error("No flightId specified for photo upload! Aborting"); @@ -35,16 +41,25 @@ export const usePhotosStore = defineStore('Photos', { return data.editPhoto; }).finally(() => this.isMutationRunning = false); }, - rotate(id, angle) { + + changeOrientation(id, direction) { this.isMutationRunning = true; - return apolloWrapper.mutate(ROTATE_PHOTO, {id, angle}).then(({data}) => { - return data.rotatePhoto; + return apolloWrapper.mutate(CHANGE_PHOTO_ORIENTATION, {id, direction}).then(({data}) => { + return data.changeOrientation; + }).finally(() => this.isMutationRunning = false); + }, + + adjust(id, adjustment) { + this.isMutationRunning = true; + return apolloWrapper.mutate(ADJUST_PHOTO, {id, adjustment}).then(({data}) => { + return data.adjustPhoto; }).finally(() => this.isMutationRunning = false); }, delete(photoId) { + this.isMutationRunning = true; return apolloWrapper.mutate(DELETE_PHOTO, {id: photoId}).then(({data}) => { return data.deletePhoto; - }); + }).finally(() => this.isMutationRunning = false); } } }); diff --git a/yarn.lock b/yarn.lock index 6bf07e5..905c903 100644 --- a/yarn.lock +++ b/yarn.lock @@ -26,6 +26,13 @@ resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.21.3.tgz" integrity sha512-lobG0d7aOfQRXh8AyklEAgZGvA4FShxo6xQbUrrT/cNBPUdIDojlokwJsQyCC/eKia7ifqM0yP+2DRZ4WKw2RQ== +"@bassist/utils@^0.4.0": + version "0.4.0" + resolved "https://registry.yarnpkg.com/@bassist/utils/-/utils-0.4.0.tgz#58eb247b37afc3c405543884731a134f522fc64b" + integrity sha512-aoFTl0jUjm8/tDZodP41wnEkvB+C5O9NFCuYN/ztL6jSUSsuBkXq90/1ifBm1XhV/zySHgLYlU1+tgo3XtQ+nA== + dependencies: + "@withtypes/mime" "^0.1.2" + "@esbuild/linux-loong64@0.14.54": version "0.14.54" resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.14.54.tgz#de2a4be678bd4d0d1ffbb86e6de779cde5999028" @@ -414,6 +421,13 @@ resolved "https://registry.npmjs.org/@vue/shared/-/shared-3.2.47.tgz" integrity sha512-BHGyyGN3Q97EZx0taMQ+OLNuZcW3d37ZEVmEAyeoA9ERdGvm9Irc/0Fua8SNyOtV1w6BS4q25wbMzJujO9HIfQ== +"@withtypes/mime@^0.1.2": + version "0.1.2" + resolved "https://registry.yarnpkg.com/@withtypes/mime/-/mime-0.1.2.tgz#956883251b0786f2c898ed76a23594ea6d9939e0" + integrity sha512-PB9BfZGzwblUONJY0LiOwsHCA6uV3DIPj/w9ReekdHxPOl0VdUFgI5s4avKycuuq9Gf5Nz2ZPA2O36GAUzlMPA== + dependencies: + mime "^3.0.0" + "@wry/context@^0.7.0": version "0.7.2" resolved "https://registry.npmjs.org/@wry/context/-/context-0.7.2.tgz" @@ -901,6 +915,11 @@ crc32-stream@^4.0.2: crc-32 "^1.2.0" readable-stream "^3.4.0" +cropperjs@^1.6.1: + version "1.6.1" + resolved "https://registry.yarnpkg.com/cropperjs/-/cropperjs-1.6.1.tgz#fd132021d93b824b1b0f2c2c3b763419fb792d89" + integrity sha512-F4wsi+XkDHCOMrHMYjrTEE4QBOrsHHN5/2VsVAaRq8P7E5z7xQpT75S+f/9WikmBEailas3+yo+6zPIomW+NOA== + cross-spawn@^7.0.2, cross-spawn@^7.0.3: version "7.0.3" resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz" @@ -2099,6 +2118,11 @@ mime@1.6.0: resolved "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz" integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== +mime@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-3.0.0.tgz#b374550dca3a0c18443b0c950a6a58f1931cf7a7" + integrity sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A== + mimic-fn@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz" @@ -2988,6 +3012,14 @@ vue-inner-image-zoom@^2.0.0: resolved "https://registry.yarnpkg.com/vue-inner-image-zoom/-/vue-inner-image-zoom-2.0.0.tgz#d84a8d5f4b76ba8d9675bfb67927aef06c4cf62c" integrity sha512-PrCppBbgffCLCpvetEWzt+8qzYs984HrhEj/9649/4nPV9leCeditl2MY5IgKWElWpDgtR3j0O2sLDVcpgGbYA== +vue-picture-cropper@^0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/vue-picture-cropper/-/vue-picture-cropper-0.7.0.tgz#e9dec1b17542ba6c681a7ba6240282193e101d68" + integrity sha512-NF7+Dgso6d0GB16E5d/BbrcTIHm1VWz8dS3IjLhoBl+ZeC+yDA46CyJphQuO32SisaPmrKHN8VbiE2LgAfhnkQ== + dependencies: + "@bassist/utils" "^0.4.0" + cropperjs "^1.6.1" + vue-router@^4.1.6: version "4.1.6" resolved "https://registry.npmjs.org/vue-router/-/vue-router-4.1.6.tgz"