mirror of https://github.com/stashapp/stash.git
Upload Image from url (#1193)
This commit is contained in:
parent
b3966b3c76
commit
55aee21cff
|
@ -26,7 +26,7 @@ func (r *mutationResolver) MovieCreate(ctx context.Context, input models.MovieCr
|
||||||
|
|
||||||
// Process the base 64 encoded image string
|
// Process the base 64 encoded image string
|
||||||
if input.FrontImage != nil {
|
if input.FrontImage != nil {
|
||||||
_, frontimageData, err = utils.ProcessBase64Image(*input.FrontImage)
|
frontimageData, err = utils.ProcessImageInput(*input.FrontImage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -34,7 +34,7 @@ func (r *mutationResolver) MovieCreate(ctx context.Context, input models.MovieCr
|
||||||
|
|
||||||
// Process the base 64 encoded image string
|
// Process the base 64 encoded image string
|
||||||
if input.BackImage != nil {
|
if input.BackImage != nil {
|
||||||
_, backimageData, err = utils.ProcessBase64Image(*input.BackImage)
|
backimageData, err = utils.ProcessImageInput(*input.BackImage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -126,7 +126,7 @@ func (r *mutationResolver) MovieUpdate(ctx context.Context, input models.MovieUp
|
||||||
var frontimageData []byte
|
var frontimageData []byte
|
||||||
frontImageIncluded := translator.hasField("front_image")
|
frontImageIncluded := translator.hasField("front_image")
|
||||||
if input.FrontImage != nil {
|
if input.FrontImage != nil {
|
||||||
_, frontimageData, err = utils.ProcessBase64Image(*input.FrontImage)
|
frontimageData, err = utils.ProcessImageInput(*input.FrontImage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -134,7 +134,7 @@ func (r *mutationResolver) MovieUpdate(ctx context.Context, input models.MovieUp
|
||||||
backImageIncluded := translator.hasField("back_image")
|
backImageIncluded := translator.hasField("back_image")
|
||||||
var backimageData []byte
|
var backimageData []byte
|
||||||
if input.BackImage != nil {
|
if input.BackImage != nil {
|
||||||
_, backimageData, err = utils.ProcessBase64Image(*input.BackImage)
|
backimageData, err = utils.ProcessImageInput(*input.BackImage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -189,7 +189,7 @@ func (r *mutationResolver) MovieUpdate(ctx context.Context, input models.MovieUp
|
||||||
// HACK - if front image is null and back image is not null, then set the front image
|
// HACK - if front image is null and back image is not null, then set the front image
|
||||||
// to the default image since we can't have a null front image and a non-null back image
|
// to the default image since we can't have a null front image and a non-null back image
|
||||||
if frontimageData == nil && backimageData != nil {
|
if frontimageData == nil && backimageData != nil {
|
||||||
_, frontimageData, _ = utils.ProcessBase64Image(models.DefaultMovieImage)
|
frontimageData, _ = utils.ProcessImageInput(models.DefaultMovieImage)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := qb.UpdateImages(movie.ID, frontimageData, backimageData); err != nil {
|
if err := qb.UpdateImages(movie.ID, frontimageData, backimageData); err != nil {
|
||||||
|
|
|
@ -18,7 +18,7 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
if input.Image != nil {
|
if input.Image != nil {
|
||||||
_, imageData, err = utils.ProcessBase64Image(*input.Image)
|
imageData, err = utils.ProcessImageInput(*input.Image)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -139,7 +139,7 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per
|
||||||
var err error
|
var err error
|
||||||
imageIncluded := translator.hasField("image")
|
imageIncluded := translator.hasField("image")
|
||||||
if input.Image != nil {
|
if input.Image != nil {
|
||||||
_, imageData, err = utils.ProcessBase64Image(*input.Image)
|
imageData, err = utils.ProcessImageInput(*input.Image)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
@ -80,7 +80,7 @@ func (r *mutationResolver) sceneUpdate(input models.SceneUpdateInput, translator
|
||||||
|
|
||||||
if input.CoverImage != nil && *input.CoverImage != "" {
|
if input.CoverImage != nil && *input.CoverImage != "" {
|
||||||
var err error
|
var err error
|
||||||
_, coverImageData, err = utils.ProcessBase64Image(*input.CoverImage)
|
coverImageData, err = utils.ProcessImageInput(*input.CoverImage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,7 +20,7 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio
|
||||||
|
|
||||||
// Process the base 64 encoded image string
|
// Process the base 64 encoded image string
|
||||||
if input.Image != nil {
|
if input.Image != nil {
|
||||||
_, imageData, err = utils.ProcessBase64Image(*input.Image)
|
imageData, err = utils.ProcessImageInput(*input.Image)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -96,7 +96,7 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio
|
||||||
imageIncluded := translator.hasField("image")
|
imageIncluded := translator.hasField("image")
|
||||||
if input.Image != nil {
|
if input.Image != nil {
|
||||||
var err error
|
var err error
|
||||||
_, imageData, err = utils.ProcessBase64Image(*input.Image)
|
imageData, err = utils.ProcessImageInput(*input.Image)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,7 +24,7 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input models.TagCreate
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
if input.Image != nil {
|
if input.Image != nil {
|
||||||
_, imageData, err = utils.ProcessBase64Image(*input.Image)
|
imageData, err = utils.ProcessImageInput(*input.Image)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
@ -82,7 +82,7 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input models.TagUpdate
|
||||||
|
|
||||||
imageIncluded := translator.hasField("image")
|
imageIncluded := translator.hasField("image")
|
||||||
if input.Image != nil {
|
if input.Image != nil {
|
||||||
_, imageData, err = utils.ProcessBase64Image(*input.Image)
|
imageData, err = utils.ProcessImageInput(*input.Image)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
|
@ -4,11 +4,66 @@ import (
|
||||||
"crypto/md5"
|
"crypto/md5"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Timeout to get the image. Includes transfer time. May want to make this
|
||||||
|
// configurable at some point.
|
||||||
|
const imageGetTimeout = time.Second * 60
|
||||||
|
|
||||||
|
const base64RE = `^data:.+\/(.+);base64,(.*)$`
|
||||||
|
|
||||||
|
// ProcessImageInput transforms an image string either from a base64 encoded
|
||||||
|
// string, or from a URL, and returns the image as a byte slice
|
||||||
|
func ProcessImageInput(imageInput string) ([]byte, error) {
|
||||||
|
regex := regexp.MustCompile(base64RE)
|
||||||
|
if regex.MatchString(imageInput) {
|
||||||
|
_, d, err := ProcessBase64Image(imageInput)
|
||||||
|
return d, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// assume input is a URL. Read it.
|
||||||
|
return ReadImageFromURL(imageInput)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadImageFromURL returns image data from a URL
|
||||||
|
func ReadImageFromURL(url string) ([]byte, error) {
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: imageGetTimeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// assume is a URL for now
|
||||||
|
|
||||||
|
// set the host of the URL as the referer
|
||||||
|
if req.URL.Scheme != "" {
|
||||||
|
req.Header.Set("Referer", req.URL.Scheme+"://"+req.Host+"/")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := ioutil.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ProcessBase64Image transforms a base64 encoded string from a form post and returns the MD5 hash of the data and the
|
// ProcessBase64Image transforms a base64 encoded string from a form post and returns the MD5 hash of the data and the
|
||||||
// image itself as a byte slice.
|
// image itself as a byte slice.
|
||||||
func ProcessBase64Image(imageString string) (string, []byte, error) {
|
func ProcessBase64Image(imageString string) (string, []byte, error) {
|
||||||
|
@ -16,7 +71,7 @@ func ProcessBase64Image(imageString string) (string, []byte, error) {
|
||||||
return "", nil, fmt.Errorf("empty image string")
|
return "", nil, fmt.Errorf("empty image string")
|
||||||
}
|
}
|
||||||
|
|
||||||
regex := regexp.MustCompile(`^data:.+\/(.+);base64,(.*)$`)
|
regex := regexp.MustCompile(base64RE)
|
||||||
matches := regex.FindStringSubmatch(imageString)
|
matches := regex.FindStringSubmatch(imageString)
|
||||||
var encodedString string
|
var encodedString string
|
||||||
if len(matches) > 2 {
|
if len(matches) > 2 {
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
* Added Performer tags.
|
* Added Performer tags.
|
||||||
|
|
||||||
### 🎨 Improvements
|
### 🎨 Improvements
|
||||||
|
* Allow scene/performer/studio image upload via URL.
|
||||||
* Add button to hide unmatched scenes in Tagger view.
|
* Add button to hide unmatched scenes in Tagger view.
|
||||||
* Hide create option in dropdowns when searching in filters.
|
* Hide create option in dropdowns when searching in filters.
|
||||||
* Add scrape gallery from fragment to UI
|
* Add scrape gallery from fragment to UI
|
||||||
|
|
|
@ -571,8 +571,10 @@ export const Movie: React.FC = () => {
|
||||||
onToggleEdit={onToggleEdit}
|
onToggleEdit={onToggleEdit}
|
||||||
onSave={onSave}
|
onSave={onSave}
|
||||||
onImageChange={onFrontImageChange}
|
onImageChange={onFrontImageChange}
|
||||||
|
onImageChangeURL={onFrontImageLoad}
|
||||||
onClearImage={onClearFrontImage}
|
onClearImage={onClearFrontImage}
|
||||||
onBackImageChange={onBackImageChange}
|
onBackImageChange={onBackImageChange}
|
||||||
|
onBackImageChangeURL={onBackImageLoad}
|
||||||
onClearBackImage={onClearBackImage}
|
onClearBackImage={onClearBackImage}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -418,6 +418,10 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
|
||||||
ImageUtils.onImageChange(event, onImageLoad);
|
ImageUtils.onImageChange(event, onImageLoad);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onImageChangeURL(url: string) {
|
||||||
|
formik.setFieldValue("image", url);
|
||||||
|
}
|
||||||
|
|
||||||
function onDisplayScrapeDialog(scraper: GQL.Scraper) {
|
function onDisplayScrapeDialog(scraper: GQL.Scraper) {
|
||||||
setIsDisplayingScraperDialog(scraper);
|
setIsDisplayingScraperDialog(scraper);
|
||||||
}
|
}
|
||||||
|
@ -533,7 +537,12 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OverlayTrigger trigger="click" placement="top" overlay={popover}>
|
<OverlayTrigger
|
||||||
|
trigger="click"
|
||||||
|
placement="top"
|
||||||
|
overlay={popover}
|
||||||
|
rootClose
|
||||||
|
>
|
||||||
<Button variant="secondary" className="mr-2">
|
<Button variant="secondary" className="mr-2">
|
||||||
Scrape with...
|
Scrape with...
|
||||||
</Button>
|
</Button>
|
||||||
|
@ -636,7 +645,11 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
|
||||||
""
|
""
|
||||||
)}
|
)}
|
||||||
{renderScraperMenu()}
|
{renderScraperMenu()}
|
||||||
<ImageInput isEditing onImageChange={onImageChangeHandler} />
|
<ImageInput
|
||||||
|
isEditing
|
||||||
|
onImageChange={onImageChangeHandler}
|
||||||
|
onImageURL={onImageChangeURL}
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
className="mx-2"
|
className="mx-2"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
|
|
|
@ -723,7 +723,11 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
||||||
alt="Scene cover"
|
alt="Scene cover"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<ImageInput isEditing onImageChange={onCoverImageChange} />
|
<ImageInput
|
||||||
|
isEditing
|
||||||
|
onImageChange={onCoverImageChange}
|
||||||
|
onImageURL={onImageLoad}
|
||||||
|
/>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -12,6 +12,8 @@ interface IProps {
|
||||||
onAutoTag?: () => void;
|
onAutoTag?: () => void;
|
||||||
onImageChange: (event: React.FormEvent<HTMLInputElement>) => void;
|
onImageChange: (event: React.FormEvent<HTMLInputElement>) => void;
|
||||||
onBackImageChange?: (event: React.FormEvent<HTMLInputElement>) => void;
|
onBackImageChange?: (event: React.FormEvent<HTMLInputElement>) => void;
|
||||||
|
onImageChangeURL?: (url: string) => void;
|
||||||
|
onBackImageChangeURL?: (url: string) => void;
|
||||||
onClearImage?: () => void;
|
onClearImage?: () => void;
|
||||||
onClearBackImage?: () => void;
|
onClearBackImage?: () => void;
|
||||||
acceptSVG?: boolean;
|
acceptSVG?: boolean;
|
||||||
|
@ -65,6 +67,7 @@ export const DetailsEditNavbar: React.FC<IProps> = (props: IProps) => {
|
||||||
isEditing={props.isEditing}
|
isEditing={props.isEditing}
|
||||||
text="Back image..."
|
text="Back image..."
|
||||||
onImageChange={props.onBackImageChange}
|
onImageChange={props.onBackImageChange}
|
||||||
|
onImageURL={props.onBackImageChangeURL}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -116,6 +119,7 @@ export const DetailsEditNavbar: React.FC<IProps> = (props: IProps) => {
|
||||||
isEditing={props.isEditing}
|
isEditing={props.isEditing}
|
||||||
text={props.onBackImageChange ? "Front image..." : undefined}
|
text={props.onBackImageChange ? "Front image..." : undefined}
|
||||||
onImageChange={props.onImageChange}
|
onImageChange={props.onImageChange}
|
||||||
|
onImageURL={props.onImageChangeURL}
|
||||||
acceptSVG={props.acceptSVG ?? false}
|
acceptSVG={props.acceptSVG ?? false}
|
||||||
/>
|
/>
|
||||||
{props.isEditing && props.onClearImage ? (
|
{props.isEditing && props.onClearImage ? (
|
||||||
|
|
|
@ -1,10 +1,20 @@
|
||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { Button, Form } from "react-bootstrap";
|
import {
|
||||||
|
Button,
|
||||||
|
Col,
|
||||||
|
Form,
|
||||||
|
OverlayTrigger,
|
||||||
|
Popover,
|
||||||
|
Row,
|
||||||
|
} from "react-bootstrap";
|
||||||
|
import { Modal } from ".";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
|
||||||
interface IImageInput {
|
interface IImageInput {
|
||||||
isEditing: boolean;
|
isEditing: boolean;
|
||||||
text?: string;
|
text?: string;
|
||||||
onImageChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
onImageChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
onImageURL?: (url: string) => void;
|
||||||
acceptSVG?: boolean;
|
acceptSVG?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -12,18 +22,107 @@ export const ImageInput: React.FC<IImageInput> = ({
|
||||||
isEditing,
|
isEditing,
|
||||||
text,
|
text,
|
||||||
onImageChange,
|
onImageChange,
|
||||||
|
onImageURL,
|
||||||
acceptSVG = false,
|
acceptSVG = false,
|
||||||
}) => {
|
}) => {
|
||||||
|
const [isShowDialog, setIsShowDialog] = useState(false);
|
||||||
|
const [url, setURL] = useState("");
|
||||||
|
|
||||||
if (!isEditing) return <div />;
|
if (!isEditing) return <div />;
|
||||||
|
|
||||||
|
if (!onImageURL) {
|
||||||
|
// just return the file input
|
||||||
|
return (
|
||||||
|
<Form.Label className="image-input">
|
||||||
|
<Button variant="secondary">{text ?? "Browse for image..."}</Button>
|
||||||
|
<Form.Control
|
||||||
|
type="file"
|
||||||
|
onChange={onImageChange}
|
||||||
|
accept={`.jpg,.jpeg,.png${acceptSVG ? ",.svg" : ""}`}
|
||||||
|
/>
|
||||||
|
</Form.Label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onConfirmURL() {
|
||||||
|
if (!onImageURL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsShowDialog(false);
|
||||||
|
onImageURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDialog() {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
show={!!isShowDialog}
|
||||||
|
onHide={() => setIsShowDialog(false)}
|
||||||
|
header="Image URL"
|
||||||
|
accept={{ onClick: onConfirmURL, text: "Confirm" }}
|
||||||
|
>
|
||||||
|
<div className="dialog-content">
|
||||||
|
<Form.Group controlId="url" as={Row}>
|
||||||
|
<Form.Label column xs={3}>
|
||||||
|
URL
|
||||||
|
</Form.Label>
|
||||||
|
<Col xs={9}>
|
||||||
|
<Form.Control
|
||||||
|
className="text-input"
|
||||||
|
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setURL(event.currentTarget.value)
|
||||||
|
}
|
||||||
|
value={url}
|
||||||
|
placeholder="URL"
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Form.Group>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const popover = (
|
||||||
|
<Popover id="set-image-popover">
|
||||||
|
<Popover.Content>
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Form.Label className="image-input">
|
||||||
|
<Button variant="secondary">
|
||||||
|
<Icon icon="file" className="fa-fw" />
|
||||||
|
<span>From file...</span>
|
||||||
|
</Button>
|
||||||
|
<Form.Control
|
||||||
|
type="file"
|
||||||
|
onChange={onImageChange}
|
||||||
|
accept={`.jpg,.jpeg,.png${acceptSVG ? ",.svg" : ""}`}
|
||||||
|
/>
|
||||||
|
</Form.Label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Button className="minimal" onClick={() => setIsShowDialog(true)}>
|
||||||
|
<Icon icon="link" className="fa-fw" />
|
||||||
|
<span>From URL...</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form.Label className="image-input">
|
<>
|
||||||
<Button variant="secondary">{text ?? "Browse for image..."}</Button>
|
{renderDialog()}
|
||||||
<Form.Control
|
<OverlayTrigger
|
||||||
type="file"
|
trigger="click"
|
||||||
onChange={onImageChange}
|
placement="top"
|
||||||
accept={`.jpg,.jpeg,.png${acceptSVG ? ",.svg" : ""}`}
|
overlay={popover}
|
||||||
/>
|
rootClose
|
||||||
</Form.Label>
|
>
|
||||||
|
<Button variant="secondary" className="mr-2">
|
||||||
|
{text ?? "Set image..."}
|
||||||
|
</Button>
|
||||||
|
</OverlayTrigger>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -319,6 +319,7 @@ export const Studio: React.FC = () => {
|
||||||
onToggleEdit={onToggleEdit}
|
onToggleEdit={onToggleEdit}
|
||||||
onSave={onSave}
|
onSave={onSave}
|
||||||
onImageChange={onImageChangeHandler}
|
onImageChange={onImageChangeHandler}
|
||||||
|
onImageChangeURL={onImageLoad}
|
||||||
onClearImage={() => {
|
onClearImage={() => {
|
||||||
onClearImage();
|
onClearImage();
|
||||||
}}
|
}}
|
||||||
|
|
Loading…
Reference in New Issue