|
1 | | -import React, { useCallback } from "react"; |
| 1 | +import React, { |
| 2 | + forwardRef, |
| 3 | + useCallback, |
| 4 | + useImperativeHandle, |
| 5 | + useState, |
| 6 | +} from "react"; |
2 | 7 | import { DropzoneOptions } from "react-dropzone"; |
3 | 8 | import { PhotoProvider, PhotoView } from "react-photo-view"; |
4 | 9 | import { v7 as uuidv7 } from "uuid"; |
@@ -75,140 +80,168 @@ export type ImageUploadProps = { |
75 | 80 | children?: React.ReactNode; |
76 | 81 | }; |
77 | 82 |
|
78 | | -export function ImageUpload(props: ImageUploadProps) { |
79 | | - const { |
80 | | - width = 100, |
81 | | - height = width, |
82 | | - value = [], |
83 | | - max = Infinity, |
84 | | - onChange, |
85 | | - onUpload = defaultUpload, |
86 | | - readonly, |
87 | | - dropzoneOptions, |
88 | | - photoProviderProps, |
89 | | - className, |
90 | | - itemClassName, |
91 | | - dropzoneClassName, |
92 | | - children, |
93 | | - } = props; |
94 | | - |
95 | | - const [images, setImages] = React.useState<ImageItem[]>(() => { |
96 | | - let valueInner = value; |
97 | | - if (!Array.isArray(valueInner)) { |
98 | | - valueInner = [valueInner]; |
99 | | - } |
100 | | - |
101 | | - return valueInner |
102 | | - .map<ImageItem>((item) => { |
103 | | - if (typeof item === "string") { |
104 | | - item = { url: item }; |
| 83 | +export type ImageUploadRef = { |
| 84 | + reset: (value?: string | ValueItem | (string | ValueItem)[]) => void; |
| 85 | + value: ImageItem[]; |
| 86 | +}; |
| 87 | + |
| 88 | +export const ImageUpload = forwardRef<ImageUploadRef, ImageUploadProps>( |
| 89 | + (props, ref) => { |
| 90 | + const { |
| 91 | + width = 100, |
| 92 | + height = width, |
| 93 | + value = [], |
| 94 | + max = Infinity, |
| 95 | + onChange, |
| 96 | + onUpload = defaultUpload, |
| 97 | + readonly, |
| 98 | + dropzoneOptions, |
| 99 | + photoProviderProps, |
| 100 | + className, |
| 101 | + itemClassName, |
| 102 | + dropzoneClassName, |
| 103 | + children, |
| 104 | + } = props; |
| 105 | + |
| 106 | + const getImages = useCallback( |
| 107 | + (value: string | ValueItem | (string | ValueItem)[]) => { |
| 108 | + if (!Array.isArray(value)) { |
| 109 | + value = [value]; |
105 | 110 | } |
106 | | - return { |
107 | | - id: uuidv7(), |
108 | | - ...item, |
109 | | - }; |
110 | | - }) |
111 | | - .slice(0, max); |
112 | | - }); |
113 | | - |
114 | | - const onDropAccepted = useCallback( |
115 | | - async (acceptedFiles: File[]) => { |
116 | | - const newImages = acceptedFiles |
117 | | - .slice(0, max - images.length) |
118 | | - .map<ImageItem>((file) => ({ |
119 | | - id: uuidv7(), |
120 | | - name: file.name, |
121 | | - loading: true, |
122 | | - file, |
123 | | - })); |
124 | | - setImages((images) => { |
125 | | - images = images.concat(newImages); |
126 | | - Promise.all( |
127 | | - newImages.map(async (item) => { |
128 | | - item.url = await onUpload?.(item.file); |
129 | | - item.loading = false; |
130 | | - setImages((images) => { |
131 | | - return [...images]; |
132 | | - }); |
| 111 | + |
| 112 | + return value |
| 113 | + .map<ImageItem>((item) => { |
| 114 | + if (typeof item === "string") { |
| 115 | + item = { url: item }; |
| 116 | + } |
| 117 | + return { |
| 118 | + id: uuidv7(), |
| 119 | + ...item, |
| 120 | + }; |
133 | 121 | }) |
134 | | - ).then(() => onChange?.(images)); |
| 122 | + .slice(0, max); |
| 123 | + }, |
| 124 | + [max] |
| 125 | + ); |
| 126 | + |
| 127 | + const [images, setImages] = useState<ImageItem[]>(() => getImages(value)); |
| 128 | + |
| 129 | + useImperativeHandle( |
| 130 | + ref, |
| 131 | + () => ({ |
| 132 | + reset: (val = value) => { |
| 133 | + setImages(getImages(val)); |
| 134 | + }, |
| 135 | + get value() { |
| 136 | + return images; |
| 137 | + }, |
| 138 | + }), |
| 139 | + [value, images] |
| 140 | + ); |
| 141 | + |
| 142 | + const onDropAccepted = useCallback( |
| 143 | + async (acceptedFiles: File[]) => { |
| 144 | + const newImages = acceptedFiles |
| 145 | + .slice(0, max - images.length) |
| 146 | + .map<ImageItem>((file) => ({ |
| 147 | + id: uuidv7(), |
| 148 | + name: file.name, |
| 149 | + loading: true, |
| 150 | + file, |
| 151 | + })); |
| 152 | + setImages((images) => { |
| 153 | + images = images.concat(newImages); |
| 154 | + Promise.all( |
| 155 | + newImages.map(async (item) => { |
| 156 | + item.url = await onUpload?.(item.file); |
| 157 | + item.loading = false; |
| 158 | + setImages((images) => { |
| 159 | + return [...images]; |
| 160 | + }); |
| 161 | + }) |
| 162 | + ).then(() => onChange?.(images)); |
| 163 | + return images; |
| 164 | + }); |
| 165 | + }, |
| 166 | + [images.length, max] |
| 167 | + ); |
| 168 | + |
| 169 | + const onRemoveImage = useCallback((idx: number) => { |
| 170 | + setImages((images) => { |
| 171 | + images = images.filter((_, index) => index != idx); |
| 172 | + onChange?.(images); |
135 | 173 | return images; |
136 | 174 | }); |
137 | | - }, |
138 | | - [images.length, max] |
139 | | - ); |
140 | | - |
141 | | - const onRemoveImage = useCallback((idx: number) => { |
142 | | - setImages((images) => { |
143 | | - images = images.filter((_, index) => index != idx); |
144 | | - onChange?.(images); |
145 | | - return images; |
146 | | - }); |
147 | | - }, []); |
148 | | - |
149 | | - return ( |
150 | | - <PhotoProvider {...photoProviderProps}> |
151 | | - <div className={clsx("ImageUpload__root", className)}> |
152 | | - <AnimatePresence mode="popLayout"> |
153 | | - {images.map((item, idx) => ( |
154 | | - <motion.div |
155 | | - key={item.id} |
156 | | - className={clsx("ImageUpload__item", itemClassName)} |
157 | | - style={{ height, width }} |
158 | | - initial={{ opacity: 0 }} |
159 | | - animate={{ opacity: 1 }} |
160 | | - exit={{ opacity: 0 }} |
161 | | - > |
162 | | - {item.loading ? ( |
163 | | - <div className="ImageUpload__loading"></div> |
164 | | - ) : ( |
165 | | - <> |
166 | | - <img |
167 | | - src={item.url} |
168 | | - className="ImageUpload__img" |
169 | | - alt={item.name} |
170 | | - /> |
171 | | - <PhotoView src={item.url}> |
172 | | - <div className="ImageUpload__preview" title="Preview image"> |
173 | | - <PreviewIcon /> |
174 | | - </div> |
175 | | - </PhotoView> |
176 | | - {!readonly && ( |
177 | | - <span |
178 | | - onClick={() => onRemoveImage(idx)} |
179 | | - className="ImageUpload__remove" |
180 | | - title="Remove image" |
181 | | - > |
182 | | - <RemoveIcon /> |
183 | | - </span> |
184 | | - )} |
185 | | - </> |
186 | | - )} |
187 | | - </motion.div> |
188 | | - ))} |
189 | | - {!readonly && images.length < max && ( |
190 | | - <motion.div |
191 | | - key="dropzone" |
192 | | - initial={{ opacity: 0 }} |
193 | | - animate={{ opacity: 1 }} |
194 | | - exit={{ opacity: 0 }} |
195 | | - > |
196 | | - <Dropzone |
197 | | - options={{ |
198 | | - onDropAccepted, |
199 | | - accept: { "image/*": [] }, |
200 | | - ...dropzoneOptions, |
201 | | - }} |
202 | | - className={dropzoneClassName} |
203 | | - width={width} |
204 | | - height={height} |
| 175 | + }, []); |
| 176 | + |
| 177 | + return ( |
| 178 | + <PhotoProvider {...photoProviderProps}> |
| 179 | + <div className={clsx("ImageUpload__root", className)}> |
| 180 | + <AnimatePresence mode="popLayout"> |
| 181 | + {images.map((item, idx) => ( |
| 182 | + <motion.div |
| 183 | + key={item.id} |
| 184 | + className={clsx("ImageUpload__item", itemClassName)} |
| 185 | + style={{ height, width }} |
| 186 | + initial={{ opacity: 0 }} |
| 187 | + animate={{ opacity: 1 }} |
| 188 | + exit={{ opacity: 0 }} |
205 | 189 | > |
206 | | - {children} |
207 | | - </Dropzone> |
208 | | - </motion.div> |
209 | | - )} |
210 | | - </AnimatePresence> |
211 | | - </div> |
212 | | - </PhotoProvider> |
213 | | - ); |
214 | | -} |
| 190 | + {item.loading ? ( |
| 191 | + <div className="ImageUpload__loading"></div> |
| 192 | + ) : ( |
| 193 | + <> |
| 194 | + <img |
| 195 | + src={item.url} |
| 196 | + className="ImageUpload__img" |
| 197 | + alt={item.name} |
| 198 | + /> |
| 199 | + <PhotoView src={item.url}> |
| 200 | + <div |
| 201 | + className="ImageUpload__preview" |
| 202 | + title="Preview image" |
| 203 | + > |
| 204 | + <PreviewIcon /> |
| 205 | + </div> |
| 206 | + </PhotoView> |
| 207 | + {!readonly && ( |
| 208 | + <span |
| 209 | + onClick={() => onRemoveImage(idx)} |
| 210 | + className="ImageUpload__remove" |
| 211 | + title="Remove image" |
| 212 | + > |
| 213 | + <RemoveIcon /> |
| 214 | + </span> |
| 215 | + )} |
| 216 | + </> |
| 217 | + )} |
| 218 | + </motion.div> |
| 219 | + ))} |
| 220 | + {!readonly && images.length < max && ( |
| 221 | + <motion.div |
| 222 | + key="dropzone" |
| 223 | + animate={{ opacity: 1 }} |
| 224 | + exit={{ opacity: 0 }} |
| 225 | + > |
| 226 | + <Dropzone |
| 227 | + options={{ |
| 228 | + onDropAccepted, |
| 229 | + accept: { "image/*": [] }, |
| 230 | + ...dropzoneOptions, |
| 231 | + }} |
| 232 | + className={dropzoneClassName} |
| 233 | + width={width} |
| 234 | + height={height} |
| 235 | + > |
| 236 | + {children} |
| 237 | + </Dropzone> |
| 238 | + </motion.div> |
| 239 | + )} |
| 240 | + </AnimatePresence> |
| 241 | + </div> |
| 242 | + </PhotoProvider> |
| 243 | + ); |
| 244 | + } |
| 245 | +); |
| 246 | + |
| 247 | +ImageUpload.displayName = "ImageUpload"; |
0 commit comments