From 73854fe5be87751adb002135051ce9abac920201 Mon Sep 17 00:00:00 2001 From: dog Date: Sun, 21 Jan 2024 16:51:10 +0300 Subject: [PATCH 1/4] prepare image crop component --- .../ImageCropper/ImageCropper.module.scss | 0 .../_shared/ImageCropper/ImageCropper.tsx | 1 + src/components/_shared/ImageCropper/index.tsx | 105 ++++++++++++++++++ 3 files changed, 106 insertions(+) create mode 100644 src/components/_shared/ImageCropper/ImageCropper.module.scss create mode 100644 src/components/_shared/ImageCropper/ImageCropper.tsx create mode 100644 src/components/_shared/ImageCropper/index.tsx diff --git a/src/components/_shared/ImageCropper/ImageCropper.module.scss b/src/components/_shared/ImageCropper/ImageCropper.module.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/components/_shared/ImageCropper/ImageCropper.tsx b/src/components/_shared/ImageCropper/ImageCropper.tsx new file mode 100644 index 00000000..08e025d1 --- /dev/null +++ b/src/components/_shared/ImageCropper/ImageCropper.tsx @@ -0,0 +1 @@ +export { ImageCropper } from './ImageCropper' diff --git a/src/components/_shared/ImageCropper/index.tsx b/src/components/_shared/ImageCropper/index.tsx new file mode 100644 index 00000000..a04b027b --- /dev/null +++ b/src/components/_shared/ImageCropper/index.tsx @@ -0,0 +1,105 @@ +import { createSignal, Show } from 'solid-js' +import { createStore } from 'solid-js/store' +import Cropper from 'cropperjs' + +import styles from './ImageCropper.module.scss' + +export default function ImageCropper(props) { + let cropImage + const [state, setState] = createStore({ + error: null, + loading: false, + file: {}, + croppedImage: null, + }), + [dropZoneActive, setDropZoneActive] = createSignal(false), + [uploading, setUploading] = createSignal(false), + [preview, setPreview] = createSignal(null), + [cropper, setCropper] = createSignal(null), + noPropagate = (e) => { + e.preventDefault() + }, + uploadFile = async (file) => { + if (!file) return + setUploading(true) + setState('loading', true) + setState('file', file) + try { + const reader = new FileReader() + reader.onload = (e) => { + setPreview(e.target.result) + setCropper( + new Cropper(cropImage, { + aspectRatio: 1 / 1, + viewMode: 1, + rotatable: false, + }), + ) + } + reader.readAsDataURL(file) + } catch (e) { + console.error('upload failed', e) + const message = e instanceof Error ? e.message : String(e) + setState('error', message) + } + setState('loading', false) + setUploading(false) + }, + handleFileDrop = async (e) => { + e.preventDefault() + setDropZoneActive(false) + uploadFile(e.dataTransfer.files[0]) + }, + handleFileInput = async (e) => { + e.preventDefault() + uploadFile(e.currentTarget.files[0]) + } + + return ( + <> + +
+
+ cropper +
+ +
+
+ +
+
(uploading() ? undefined : setDropZoneActive(true))} + onDragLeave={() => setDropZoneActive(false)} + onDragOver={noPropagate} + onDrop={(event) => (uploading() ? noPropagate(event) : handleFileDrop(event))} + > +
upload
+ +
+
+ + + + ) +} From 3429b36502fb945e738dffbdddb8da0b687a9102 Mon Sep 17 00:00:00 2001 From: dog Date: Mon, 22 Jan 2024 14:06:32 +0300 Subject: [PATCH 2/4] add cropperjs --- package-lock.json | 6 + package.json | 1 + .../_shared/ImageCropper/ImageCropper.tsx | 106 +++++++++++++++++- src/components/_shared/ImageCropper/index.tsx | 106 +----------------- 4 files changed, 113 insertions(+), 106 deletions(-) diff --git a/package-lock.json b/package-lock.json index f25bccad..e6790003 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.8.0", "license": "MIT", "dependencies": { + "cropperjs": "1.6.1", "form-data": "4.0.0", "i18next": "22.4.15", "i18next-icu": "2.3.0", @@ -7865,6 +7866,11 @@ "dev": true, "peer": true }, + "node_modules/cropperjs": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-1.6.1.tgz", + "integrity": "sha512-F4wsi+XkDHCOMrHMYjrTEE4QBOrsHHN5/2VsVAaRq8P7E5z7xQpT75S+f/9WikmBEailas3+yo+6zPIomW+NOA==" + }, "node_modules/cross-env": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", diff --git a/package.json b/package.json index 1c9101ed..9a635502 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "typecheck:watch": "tsc --noEmit --watch" }, "dependencies": { + "cropperjs": "1.6.1", "form-data": "4.0.0", "i18next": "22.4.15", "i18next-icu": "2.3.0", diff --git a/src/components/_shared/ImageCropper/ImageCropper.tsx b/src/components/_shared/ImageCropper/ImageCropper.tsx index 08e025d1..99d86faf 100644 --- a/src/components/_shared/ImageCropper/ImageCropper.tsx +++ b/src/components/_shared/ImageCropper/ImageCropper.tsx @@ -1 +1,105 @@ -export { ImageCropper } from './ImageCropper' +import { createSignal, Show } from 'solid-js' +import { createStore } from 'solid-js/store' +import Cropper from 'cropperjs' + +import styles from './ImageCropper.module.scss' + +export const ImageCropper = (props) => { + let cropImage + const [state, setState] = createStore({ + error: null, + loading: false, + file: {}, + croppedImage: null, + }), + [dropZoneActive, setDropZoneActive] = createSignal(false), + [uploading, setUploading] = createSignal(false), + [preview, setPreview] = createSignal(null), + [cropper, setCropper] = createSignal(null), + noPropagate = (e) => { + e.preventDefault() + }, + uploadFile = async (file) => { + if (!file) return + setUploading(true) + setState('loading', true) + setState('file', file) + try { + const reader = new FileReader() + reader.onload = (e) => { + setPreview(e.target.result) + setCropper( + new Cropper(cropImage, { + aspectRatio: 1 / 1, + viewMode: 1, + rotatable: false, + }), + ) + } + reader.readAsDataURL(file) + } catch (e) { + console.error('upload failed', e) + const message = e instanceof Error ? e.message : String(e) + setState('error', message) + } + setState('loading', false) + setUploading(false) + }, + handleFileDrop = async (e) => { + e.preventDefault() + setDropZoneActive(false) + uploadFile(e.dataTransfer.files[0]) + }, + handleFileInput = async (e) => { + e.preventDefault() + uploadFile(e.currentTarget.files[0]) + } + + return ( + <> + +
+
+ cropper +
+ +
+
+ +
+
(uploading() ? undefined : setDropZoneActive(true))} + onDragLeave={() => setDropZoneActive(false)} + onDragOver={noPropagate} + onDrop={(event) => (uploading() ? noPropagate(event) : handleFileDrop(event))} + > +
upload
+ +
+
+ + + + ) +} diff --git a/src/components/_shared/ImageCropper/index.tsx b/src/components/_shared/ImageCropper/index.tsx index a04b027b..08e025d1 100644 --- a/src/components/_shared/ImageCropper/index.tsx +++ b/src/components/_shared/ImageCropper/index.tsx @@ -1,105 +1 @@ -import { createSignal, Show } from 'solid-js' -import { createStore } from 'solid-js/store' -import Cropper from 'cropperjs' - -import styles from './ImageCropper.module.scss' - -export default function ImageCropper(props) { - let cropImage - const [state, setState] = createStore({ - error: null, - loading: false, - file: {}, - croppedImage: null, - }), - [dropZoneActive, setDropZoneActive] = createSignal(false), - [uploading, setUploading] = createSignal(false), - [preview, setPreview] = createSignal(null), - [cropper, setCropper] = createSignal(null), - noPropagate = (e) => { - e.preventDefault() - }, - uploadFile = async (file) => { - if (!file) return - setUploading(true) - setState('loading', true) - setState('file', file) - try { - const reader = new FileReader() - reader.onload = (e) => { - setPreview(e.target.result) - setCropper( - new Cropper(cropImage, { - aspectRatio: 1 / 1, - viewMode: 1, - rotatable: false, - }), - ) - } - reader.readAsDataURL(file) - } catch (e) { - console.error('upload failed', e) - const message = e instanceof Error ? e.message : String(e) - setState('error', message) - } - setState('loading', false) - setUploading(false) - }, - handleFileDrop = async (e) => { - e.preventDefault() - setDropZoneActive(false) - uploadFile(e.dataTransfer.files[0]) - }, - handleFileInput = async (e) => { - e.preventDefault() - uploadFile(e.currentTarget.files[0]) - } - - return ( - <> - -
-
- cropper -
- -
-
- -
-
(uploading() ? undefined : setDropZoneActive(true))} - onDragLeave={() => setDropZoneActive(false)} - onDragOver={noPropagate} - onDrop={(event) => (uploading() ? noPropagate(event) : handleFileDrop(event))} - > -
upload
- -
-
- - - - ) -} +export { ImageCropper } from './ImageCropper' From 591fd2ecbfd5e5794824306f9ea1585113dfb60a Mon Sep 17 00:00:00 2001 From: dog Date: Thu, 25 Jan 2024 15:41:25 +0300 Subject: [PATCH 3/4] implement image crop --- public/locales/en/translation.json | 1 + public/locales/ru/translation.json | 1 + .../ProfileSettings/ProfileSettings.tsx | 67 +++++-- .../ImageCropper/ImageCropper.module.scss | 6 + .../_shared/ImageCropper/ImageCropper.tsx | 165 ++++++++---------- src/stores/ui.ts | 2 + src/styles/app.scss | 28 +++ 7 files changed, 157 insertions(+), 113 deletions(-) diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index fc967d81..2c65fac2 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -105,6 +105,7 @@ "Create gallery": "Create gallery", "Create post": "Create post", "Create video": "Create video", + "Crop image": "Crop image", "Culture": "Culture", "Date of Birth": "Date of Birth", "Decline": "Decline", diff --git a/public/locales/ru/translation.json b/public/locales/ru/translation.json index 5694bc44..ab5481e8 100644 --- a/public/locales/ru/translation.json +++ b/public/locales/ru/translation.json @@ -109,6 +109,7 @@ "Create gallery": "Создать галерею", "Create post": "Создать публикацию", "Create video": "Создать видео", + "Crop image": "Скадрируйте изображение", "Culture": "Культура", "Date of Birth": "Дата рождения", "Decline": "Отмена", diff --git a/src/components/ProfileSettings/ProfileSettings.tsx b/src/components/ProfileSettings/ProfileSettings.tsx index 1cc18e4d..176a7fa8 100644 --- a/src/components/ProfileSettings/ProfileSettings.tsx +++ b/src/components/ProfileSettings/ProfileSettings.tsx @@ -14,13 +14,18 @@ import { getImageUrl } from '../../utils/getImageUrl' import { handleImageUpload } from '../../utils/handleImageUpload' import { profileSocialLinks } from '../../utils/profileSocialLinks' import { validateUrl } from '../../utils/validateUrl' + +import { Modal } from '../Nav/Modal' import { Button } from '../_shared/Button' import { Icon } from '../_shared/Icon' import { Loading } from '../_shared/Loading' import { Popover } from '../_shared/Popover' import { SocialNetworkInput } from '../_shared/SocialNetworkInput' +import { ImageCropper } from '../_shared/ImageCropper' import { ProfileSettingsNavigation } from '../Nav/ProfileSettingsNavigation' +import { showModal, hideModal } from '../../stores/ui' + import styles from '../../pages/profile/Settings.module.scss' const SimplifiedEditor = lazy(() => import('../../components/Editor/SimplifiedEditor')) @@ -28,12 +33,14 @@ const GrowingTextarea = lazy(() => import('../../components/_shared/GrowingTexta export const ProfileSettings = () => { const { t } = useLocalize() + const [prevForm, setPrevForm] = createStore({}) const [isFormInitialized, setIsFormInitialized] = createSignal(false) const [social, setSocial] = createSignal([]) const [addLinkForm, setAddLinkForm] = createSignal(false) const [incorrectUrl, setIncorrectUrl] = createSignal(false) const [isUserpicUpdating, setIsUserpicUpdating] = createSignal(false) + const [userpicFile, setUserpicFile] = createSignal(null) const [uploadError, setUploadError] = createSignal(false) const [isFloatingPanelVisible, setIsFloatingPanelVisible] = createSignal(false) const [hostname, setHostname] = createSignal(null) @@ -115,23 +122,32 @@ export const ProfileSettings = () => { } } - const { selectFiles } = createFileUploader({ multiple: false, accept: 'image/*' }) + const handleCropAvatar = () => { + const { selectFiles } = createFileUploader({ multiple: false, accept: 'image/*' }) - const handleUploadAvatar = async () => { - selectFiles(async ([uploadFile]) => { - try { - setUploadError(false) - setIsUserpicUpdating(true) - const result = await handleImageUpload(uploadFile) - updateFormField('userpic', result.url) - setIsUserpicUpdating(false) - } catch (error) { - setUploadError(true) - console.error('[upload avatar] error', error) - } + selectFiles(([uploadFile]) => { + setUserpicFile(uploadFile) + + showModal('cropImage') }) } + const handleUploadAvatar = async (uploadFile) => { + try { + setUploadError(false) + setIsUserpicUpdating(true) + + const result = await handleImageUpload(uploadFile) + updateFormField('userpic', result.url) + + setUserpicFile(null) + setIsUserpicUpdating(false) + } catch (error) { + setUploadError(true) + console.error('[upload avatar] error', error) + } + } + onMount(() => { setHostname(window?.location.host) @@ -178,7 +194,7 @@ export const ProfileSettings = () => {
@@ -206,17 +222,19 @@ export const ProfileSettings = () => { )} - + + {/* @@TODO inspect popover below. onClick causes page refreshing */} + {/* {(triggerRef: (el) => void) => ( )} - + */}
@@ -365,6 +383,21 @@ export const ProfileSettings = () => {
+ setUserpicFile(null)}> +

{t('Crop image')}

+ + + { + handleUploadAvatar(data) + + hideModal() + }} + onDecline={() => hideModal()} + /> + +
) diff --git a/src/components/_shared/ImageCropper/ImageCropper.module.scss b/src/components/_shared/ImageCropper/ImageCropper.module.scss index e69de29b..19d5ff12 100644 --- a/src/components/_shared/ImageCropper/ImageCropper.module.scss +++ b/src/components/_shared/ImageCropper/ImageCropper.module.scss @@ -0,0 +1,6 @@ +.cropperControls { + display: flex; + justify-content: space-between; + + margin-top: 2rem; +} diff --git a/src/components/_shared/ImageCropper/ImageCropper.tsx b/src/components/_shared/ImageCropper/ImageCropper.tsx index 99d86faf..1d5caded 100644 --- a/src/components/_shared/ImageCropper/ImageCropper.tsx +++ b/src/components/_shared/ImageCropper/ImageCropper.tsx @@ -1,105 +1,78 @@ -import { createSignal, Show } from 'solid-js' -import { createStore } from 'solid-js/store' +import 'cropperjs/dist/cropper.css' + +import { createSignal, onMount, Show } from 'solid-js' import Cropper from 'cropperjs' +import { UploadFile } from '@solid-primitives/upload' + +import { useLocalize } from '../../../context/localize' + +import { Button } from '../Button' import styles from './ImageCropper.module.scss' -export const ImageCropper = (props) => { - let cropImage - const [state, setState] = createStore({ - error: null, - loading: false, - file: {}, - croppedImage: null, - }), - [dropZoneActive, setDropZoneActive] = createSignal(false), - [uploading, setUploading] = createSignal(false), - [preview, setPreview] = createSignal(null), - [cropper, setCropper] = createSignal(null), - noPropagate = (e) => { - e.preventDefault() - }, - uploadFile = async (file) => { - if (!file) return - setUploading(true) - setState('loading', true) - setState('file', file) - try { - const reader = new FileReader() - reader.onload = (e) => { - setPreview(e.target.result) - setCropper( - new Cropper(cropImage, { - aspectRatio: 1 / 1, - viewMode: 1, - rotatable: false, - }), - ) - } - reader.readAsDataURL(file) - } catch (e) { - console.error('upload failed', e) - const message = e instanceof Error ? e.message : String(e) - setState('error', message) - } - setState('loading', false) - setUploading(false) - }, - handleFileDrop = async (e) => { - e.preventDefault() - setDropZoneActive(false) - uploadFile(e.dataTransfer.files[0]) - }, - handleFileInput = async (e) => { - e.preventDefault() - uploadFile(e.currentTarget.files[0]) +interface CropperProps { + uploadFile: UploadFile + onSave: (any) => void + onDecline?: () => void +} + +export const ImageCropper = (props: CropperProps) => { + const { t } = useLocalize() + + const imageTagRef: { current: HTMLImageElement } = { + current: null, + } + + const [cropper, setCropper] = createSignal(null) + + onMount(() => { + if (imageTagRef.current) { + setCropper( + new Cropper(imageTagRef.current, { + viewMode: 1, + aspectRatio: 1, + guides: false, + background: false, + rotatable: false, + modal: true, + }), + ) } + }) return ( - <> - -
-
- cropper -
- -
-
- -
-
(uploading() ? undefined : setDropZoneActive(true))} - onDragLeave={() => setDropZoneActive(false)} - onDragOver={noPropagate} - onDrop={(event) => (uploading() ? noPropagate(event) : handleFileDrop(event))} - > -
upload
- -
-
- - - +
+
+ (imageTagRef.current = el)} + src={props.uploadFile.source} + alt="image crop panel" + /> +
+ +
+ +
+
) } diff --git a/src/stores/ui.ts b/src/stores/ui.ts index 7fcb4fe5..cfaf0c9e 100644 --- a/src/stores/ui.ts +++ b/src/stores/ui.ts @@ -25,6 +25,7 @@ export type ModalType = | 'following' | 'inviteCoAuthors' | 'share' + | 'cropImage' export const MODALS: Record = { auth: 'auth', @@ -42,6 +43,7 @@ export const MODALS: Record = { following: 'following', inviteCoAuthors: 'inviteCoAuthors', share: 'share', + cropImage: 'cropImage', } const [modal, setModal] = createSignal(null) diff --git a/src/styles/app.scss b/src/styles/app.scss index 2d3a7ac2..4efca363 100644 --- a/src/styles/app.scss +++ b/src/styles/app.scss @@ -1070,3 +1070,31 @@ iframe { .img-align-column { clear: both; } + +.cropper-modal { + background-color: #fff !important; +} + +.cropper-canvas { + filter: blur(2px); +} + +.cropper-view-box, +.cropper-crop-box, +.cropper-line, +.cropper-point { + box-shadow: none !important; + outline: none !important; + border: none !important; + background-color: transparent !important; +} + +.cropper-crop-box { + border: 2px solid #000 !important; + border-radius: 8px; +} + +.cropper-view-box, +.cropper-face { + border-radius: 50%; +} From ef49e4b971f78949c758f9b191ea90ec03c3cde9 Mon Sep 17 00:00:00 2001 From: dog Date: Sat, 27 Jan 2024 14:41:00 +0300 Subject: [PATCH 4/4] limit cropper container height & fix t --- public/locales/ru/translation.json | 2 +- src/components/_shared/ImageCropper/ImageCropper.module.scss | 4 ++++ src/components/_shared/ImageCropper/ImageCropper.tsx | 3 ++- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/public/locales/ru/translation.json b/public/locales/ru/translation.json index ab5481e8..971e2067 100644 --- a/public/locales/ru/translation.json +++ b/public/locales/ru/translation.json @@ -109,7 +109,7 @@ "Create gallery": "Создать галерею", "Create post": "Создать публикацию", "Create video": "Создать видео", - "Crop image": "Скадрируйте изображение", + "Crop image": "Кадрировать изображение", "Culture": "Культура", "Date of Birth": "Дата рождения", "Decline": "Отмена", diff --git a/src/components/_shared/ImageCropper/ImageCropper.module.scss b/src/components/_shared/ImageCropper/ImageCropper.module.scss index 19d5ff12..51e4dd01 100644 --- a/src/components/_shared/ImageCropper/ImageCropper.module.scss +++ b/src/components/_shared/ImageCropper/ImageCropper.module.scss @@ -1,3 +1,7 @@ +.cropperContainer { + max-height: 55vh; +} + .cropperControls { display: flex; justify-content: space-between; diff --git a/src/components/_shared/ImageCropper/ImageCropper.tsx b/src/components/_shared/ImageCropper/ImageCropper.tsx index 1d5caded..6e2646f8 100644 --- a/src/components/_shared/ImageCropper/ImageCropper.tsx +++ b/src/components/_shared/ImageCropper/ImageCropper.tsx @@ -34,6 +34,7 @@ export const ImageCropper = (props: CropperProps) => { guides: false, background: false, rotatable: false, + autoCropArea: 1, modal: true, }), ) @@ -42,7 +43,7 @@ export const ImageCropper = (props: CropperProps) => { return (
-
+
(imageTagRef.current = el)} src={props.uploadFile.source}