diff --git a/package.json b/package.json index fe5b214..8bc3ed9 100644 --- a/package.json +++ b/package.json @@ -122,6 +122,7 @@ "prosemirror-docx": "^0.2.0", "re-resizable": "^6.10.0", "react-colorful": "^5.6.1", + "react-image-crop": "^11.0.7", "react-visibility-sensor": "^5.1.1", "scroll-into-view-if-needed": "^3.1.0", "shiki": "^1.22.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3fc9f22..610a81d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -212,6 +212,9 @@ importers: react-colorful: specifier: ^5.6.1 version: 5.6.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-image-crop: + specifier: ^11.0.7 + version: 11.0.7(react@18.3.1) react-visibility-sensor: specifier: ^5.1.1 version: 5.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -4661,11 +4664,13 @@ packages: eslint@8.57.0: resolution: {integrity: sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true eslint@8.57.1: resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true esno@4.8.0: @@ -6266,6 +6271,11 @@ packages: peerDependencies: react: ^18.3.1 + react-image-crop@11.0.7: + resolution: {integrity: sha512-ZciKWHDYzmm366JDL18CbrVyjnjH0ojufGDmScfS4ZUqLHg4nm6ATY+K62C75W4ZRNt4Ii+tX0bSjNk9LQ2xzQ==} + peerDependencies: + react: '>=16.13.1' + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -14716,6 +14726,10 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 + react-image-crop@11.0.7(react@18.3.1): + dependencies: + react: 18.3.1 + react-is@16.13.1: {} react-refresh@0.14.2: {} diff --git a/src/components/ColorPicker.tsx b/src/components/ColorPicker.tsx index bb524b0..86acd42 100644 --- a/src/components/ColorPicker.tsx +++ b/src/components/ColorPicker.tsx @@ -1,4 +1,3 @@ -/* eslint-disable react/no-duplicate-key */ import React, { useEffect, useMemo, useState } from 'react' import { Plus } from 'lucide-react' @@ -74,7 +73,7 @@ function ColorPicker(props: ColorPickerProps) { } return ( - + {props?.children} diff --git a/src/components/SizeSetter/SizeSetter.tsx b/src/components/SizeSetter/SizeSetter.tsx index bec16c2..09cbfc7 100644 --- a/src/components/SizeSetter/SizeSetter.tsx +++ b/src/components/SizeSetter/SizeSetter.tsx @@ -37,7 +37,7 @@ export const SizeSetter: React.FC = ({ width, maxWidth, height, onOk, ch } return ( - + {children} diff --git a/src/components/icons/icons.ts b/src/components/icons/icons.ts index 002dcaa..39ae556 100644 --- a/src/components/icons/icons.ts +++ b/src/components/icons/icons.ts @@ -15,6 +15,7 @@ import { Columns3, Columns4, Copy, + CropIcon, Eraser, Eye, Frame, @@ -195,4 +196,5 @@ export const icons = { Attachment: Paperclip, GifIcon, ChevronUp, + Crop: CropIcon, } as any diff --git a/src/extensions/Emoji/components/EmojiPicker/EmojiPicker.tsx b/src/extensions/Emoji/components/EmojiPicker/EmojiPicker.tsx index 1495f6f..88ec515 100644 --- a/src/extensions/Emoji/components/EmojiPicker/EmojiPicker.tsx +++ b/src/extensions/Emoji/components/EmojiPicker/EmojiPicker.tsx @@ -106,7 +106,7 @@ function EmojiPickerWrap({ onSelectEmoji, children }: IProps) { }, []) return ( - + {children} diff --git a/src/extensions/ImageGif/components/ImageGifActionButton.tsx b/src/extensions/ImageGif/components/ImageGifActionButton.tsx index 94fe53c..cacf5d0 100644 --- a/src/extensions/ImageGif/components/ImageGifActionButton.tsx +++ b/src/extensions/ImageGif/components/ImageGifActionButton.tsx @@ -56,7 +56,7 @@ function ImageGifWrap({ selectImage, giphyApiKey, children }: IProps) { ) return ( - + {children} diff --git a/src/extensions/ImageUpload/components/ImageCropper.tsx b/src/extensions/ImageUpload/components/ImageCropper.tsx new file mode 100644 index 0000000..1206d45 --- /dev/null +++ b/src/extensions/ImageUpload/components/ImageCropper.tsx @@ -0,0 +1,184 @@ +/* eslint-disable no-console */ +import React, { useRef, useState } from 'react' + +import ReactCrop, { + type Crop, + type PixelCrop, +} from 'react-image-crop' + +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogFooter, + DialogTrigger, +} from '@/components/ui/dialog' + +import { useLocale } from '@/locales' +import { dataURLtoFile, readImageAsBase64 } from '@/utils/file' +import { createImageUpload } from '@/plugins/image-upload' + +import 'react-image-crop/dist/ReactCrop.css' +import { IconComponent } from '@/components' + +export function ImageCropper({ editor, getPos }: any) { + const { t } = useLocale() + + const [dialogOpen, setDialogOpen] = useState(false) + + const imgRef = React.useRef(null) + + const [crop, setCrop] = React.useState() + const [croppedImageUrl, setCroppedImageUrl] = React.useState('') + const fileInput = useRef(null) + const [urlUpload, setUrlUpload] = useState({ + src: '', + file: null, + }) + + function onCropComplete(crop: PixelCrop) { + if (imgRef.current && crop.width && crop.height) { + const croppedImageUrl = getCroppedImg(imgRef.current, crop) + setCroppedImageUrl(croppedImageUrl) + } + } + + function getCroppedImg(image: HTMLImageElement, crop: PixelCrop): string { + const canvas = document.createElement('canvas') + const scaleX = image.naturalWidth / image.width + const scaleY = image.naturalHeight / image.height + + canvas.width = crop.width * scaleX + canvas.height = crop.height * scaleY + + const ctx = canvas.getContext('2d') + + if (ctx) { + ctx.imageSmoothingEnabled = false + + ctx.drawImage( + image, + crop.x * scaleX, + crop.y * scaleY, + crop.width * scaleX, + crop.height * scaleY, + 0, + 0, + crop.width * scaleX, + crop.height * scaleY, + ) + } + + return canvas.toDataURL('image/png', 1.0) + } + + async function onCrop() { + try { + const fileCrop = await dataURLtoFile(croppedImageUrl, urlUpload?.file?.name || 'image.png') + + const uploadOptions = editor.extensionManager.extensions.find( + (extension: any) => extension.name === 'imageUpload', + )?.options + + const uploadFn = createImageUpload({ + validateFn: () => { + return true + }, + onUpload: uploadOptions.upload, + postUpload: uploadOptions.postUpload, + }) + uploadFn([fileCrop], editor.view, getPos()) + + setDialogOpen(false) + + setUrlUpload({ + src: '', + file: null, + }) + } + catch (error) { + console.log('Error cropping image', error) + } + } + + function handleClick(e: any) { + e.preventDefault() + fileInput.current?.click() + } + + const handleFile = async (event: any) => { + const files = event?.target?.files + if (!editor || editor.isDestroyed || files.length === 0) { + return + } + const file = files[0] + + const base64 = await readImageAsBase64(file) + + setDialogOpen(true) + setUrlUpload({ + src: base64.src, + file, + }) + } + + return ( + <> + + + + + + +
+ {urlUpload.src && ( + setCrop(c)} + onComplete={c => onCropComplete(c)} + className="richtext-w-full" + > + Crop me + + )} +
+ + + + +
+
+ + + ) +} diff --git a/src/extensions/ImageUpload/components/ImageUploader.tsx b/src/extensions/ImageUpload/components/ImageUploader.tsx index 1108bd9..d6a24e8 100644 --- a/src/extensions/ImageUpload/components/ImageUploader.tsx +++ b/src/extensions/ImageUpload/components/ImageUploader.tsx @@ -5,6 +5,7 @@ import { NodeViewWrapper } from '@tiptap/react' import { Button, IconComponent, Input, Popover, PopoverContent, PopoverTrigger, Tabs, TabsContent, TabsList, TabsTrigger } from '@/components' import { useLocale } from '@/locales' import { createImageUpload } from '@/plugins/image-upload' +import { ImageCropper } from '@/extensions/ImageUpload/components/ImageCropper' function ImageUploader(props: any) { const { t } = useLocale() @@ -78,9 +79,12 @@ function ImageUploader(props: any) { - +
+ + +
+ + + {props?.children} diff --git a/src/extensions/TextAlign/components/TextAlignMenuButton.tsx b/src/extensions/TextAlign/components/TextAlignMenuButton.tsx index a2de329..bdd8569 100644 --- a/src/extensions/TextAlign/components/TextAlignMenuButton.tsx +++ b/src/extensions/TextAlign/components/TextAlignMenuButton.tsx @@ -48,7 +48,7 @@ function TextAlignMenuButton(props: IPropsTextAlignMenuButton) { }, [props]) return ( - + + { } }, }, - external: ['react', 'react-dom', 'react/jsx-runtime', 'katex', 'shiki', 'docx', '@radix-ui/react-dropdown-menu', '@radix-ui/react-icons', '@radix-ui/react-label', '@radix-ui/react-popover', '@radix-ui/react-separator', '@radix-ui/react-slot', '@radix-ui/react-switch', '@radix-ui/react-tabs', '@radix-ui/react-toast', '@radix-ui/react-toggle', '@radix-ui/react-tooltip', '@radix-ui/react-select', 'react-colorful', 'scroll-into-view-if-needed', 'tippy.js', 'valtio', 'echo-drag-handle-plugin', 'lucide-react', 'prosemirror-docx', 're-resizable', '@excalidraw/excalidraw', '@radix-ui/react-dialog'], + external: ['react', 'react-dom', 'react/jsx-runtime', 'katex', 'shiki', 'docx', '@radix-ui/react-dropdown-menu', '@radix-ui/react-icons', '@radix-ui/react-label', '@radix-ui/react-popover', '@radix-ui/react-separator', '@radix-ui/react-slot', '@radix-ui/react-switch', '@radix-ui/react-tabs', '@radix-ui/react-toast', '@radix-ui/react-toggle', '@radix-ui/react-tooltip', '@radix-ui/react-select', 'react-colorful', 'scroll-into-view-if-needed', 'tippy.js', 'valtio', 'echo-drag-handle-plugin', 'lucide-react', 'prosemirror-docx', 're-resizable', '@excalidraw/excalidraw', '@radix-ui/react-dialog', 'react-image-crop'], }, }, }