Skip to content

Commit b529453

Browse files
authored
feat(ImgSize): added ability to render custom form in image widget (#639)
1 parent f6a7899 commit b529453

File tree

6 files changed

+209
-21
lines changed

6 files changed

+209
-21
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import type {StoryObj} from '@storybook/react';
2+
3+
import {ImageCustomFormDemo as component} from './ImageCustomForm';
4+
5+
export const Story: StoryObj<typeof component> = {
6+
args: {},
7+
};
8+
Story.storyName = 'Custom Image Widget';
9+
10+
export default {
11+
title: 'Examples / Custom Image Widget',
12+
component,
13+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import {memo} from 'react';
2+
3+
import {FilePlus} from '@gravity-ui/icons';
4+
import {Button, FilePreview, Icon, useFileInput} from '@gravity-ui/uikit';
5+
6+
import {type ImgSizeOptions, MarkdownEditorView, useMarkdownEditor} from '../../../../src';
7+
import {PlaygroundLayout} from '../../../components/PlaygroundLayout';
8+
import {randomDelay} from '../../../utils/delay';
9+
10+
type RenderImageWidgetFormFn = NonNullable<ImgSizeOptions['renderImageWidgetForm']>;
11+
type RenderImageWidgetFormProps = Parameters<RenderImageWidgetFormFn>[0];
12+
13+
const ImageForm = memo<RenderImageWidgetFormProps>(function ImageForm({onSubmit, onAttach}) {
14+
const {controlProps, triggerProps} = useFileInput({onUpdate: onAttach});
15+
16+
return (
17+
<div
18+
style={{
19+
display: 'grid',
20+
padding: '12px 16px',
21+
justifyItems: 'center',
22+
alignItems: 'center',
23+
gridTemplateColumns: 'repeat(3, 1fr)',
24+
gridGap: 8,
25+
}}
26+
>
27+
{/* Rendering previews for images */}
28+
{getImages().map(({id, url}) => {
29+
const name = id;
30+
return (
31+
<FilePreview
32+
key={id}
33+
imageSrc={url}
34+
file={{name, type: 'image/png'} as File}
35+
onClick={() => onSubmit({url, name, alt: '', width: 320, height: 320})}
36+
/>
37+
);
38+
})}
39+
40+
{/* Rendering a button for uploading images from device */}
41+
<Button
42+
size="xl"
43+
view="flat-secondary"
44+
width="max"
45+
style={
46+
{
47+
'--g-button-height': '100%',
48+
'--g-button-border-radius': '4px',
49+
} as React.CSSProperties
50+
}
51+
{...triggerProps}
52+
>
53+
<Icon data={FilePlus} width={36} height={36} />
54+
</Button>
55+
<input type="file" multiple={false} accept="image/*" {...controlProps} />
56+
</div>
57+
);
58+
});
59+
60+
export const ImageCustomFormDemo = memo(() => {
61+
const editor = useMarkdownEditor({
62+
initial: {
63+
mode: 'wysiwyg',
64+
markup: '&nbsp;\n\nClick the `Image` action on the toolbar or select it from the slash `/` menu.',
65+
},
66+
handlers: {
67+
uploadFile: async (file) => {
68+
await randomDelay(1000, 3000);
69+
return {url: URL.createObjectURL(file)};
70+
},
71+
},
72+
wysiwygConfig: {
73+
extensionOptions: {
74+
imgSize: {
75+
// pass a function to render custom form in the image widget
76+
renderImageWidgetForm: (props) => <ImageForm {...props} />,
77+
},
78+
},
79+
},
80+
});
81+
82+
return (
83+
<PlaygroundLayout
84+
editor={editor}
85+
view={({className}) => (
86+
<MarkdownEditorView
87+
autofocus
88+
stickyToolbar
89+
settingsVisible
90+
editor={editor}
91+
className={className}
92+
/>
93+
)}
94+
/>
95+
);
96+
});
97+
98+
ImageCustomFormDemo.displayName = 'ImageCustomFormDemo';
99+
100+
type ImageItem = {
101+
id: string;
102+
url: string;
103+
};
104+
105+
function getImages(): ImageItem[] {
106+
return [
107+
{
108+
id: 'low',
109+
url: 'https://avatars.mds.yandex.net/get-shedevrum/14441318/img_1c3b6b42eee211efad66ea120268400c/orig',
110+
},
111+
{
112+
id: 'unsatisfactory',
113+
url: 'https://avatars.mds.yandex.net/get-shedevrum/15170052/img_7ba17345eee211efa1d9c61932b2752e/orig',
114+
},
115+
{
116+
id: 'good',
117+
url: 'https://avatars.mds.yandex.net/get-shedevrum/16106905/img_26cb5157eee311efb16cf600b5cb441c/orig',
118+
},
119+
{
120+
id: 'outstanding',
121+
url: 'https://avatars.mds.yandex.net/get-shedevrum/14441318/img_834891dceee111ef80908e055cc35a5d/orig',
122+
},
123+
{
124+
id: 'amazing',
125+
url: 'https://avatars.mds.yandex.net/get-shedevrum/15320627/img_d62906eeeee211ef9e61968caa0a2b17/orig',
126+
},
127+
];
128+
}

src/extensions/yfm/ImgSize/ImageWidget/index.ts

+3
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type {Action, ExtensionAuto} from '../../../../core';
22
import type {FileUploadHandler} from '../../../../utils/upload';
33

44
import {addImageWidget} from './actions';
5+
import type {RenderImageWidgetFormFn} from './view';
56
import type {ImageWidgetDescriptorOpts} from './widget';
67

78
const addImageWidgetAction = 'addImageWidget';
@@ -11,12 +12,14 @@ export type ImageWidgetOptions = Pick<
1112
'needToSetDimensionsForUploadedImages' | 'enableNewImageSizeCalculation'
1213
> & {
1314
imageUploadHandler?: FileUploadHandler;
15+
renderImageWidgetForm?: RenderImageWidgetFormFn;
1416
};
1517

1618
export const ImageWidget: ExtensionAuto<ImageWidgetOptions> = (builder, opts) => {
1719
builder.addAction(addImageWidgetAction, (deps) =>
1820
addImageWidget(deps, {
1921
uploadImages: opts.imageUploadHandler,
22+
renderImageForm: opts.renderImageWidgetForm,
2023
needToSetDimensionsForUploadedImages: opts.needToSetDimensionsForUploadedImages,
2124
enableNewImageSizeCalculation: opts.enableNewImageSizeCalculation,
2225
}),

src/extensions/yfm/ImgSize/ImageWidget/view.tsx

+47-14
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ import {Icon, Popup, type PopupPlacement} from '@gravity-ui/uikit';
55
import {useMountedState} from 'react-use';
66

77
import {cn} from '../../../../classname';
8-
import {ImageForm, type ImageFormProps} from '../../../../forms/ImageForm';
8+
import {
9+
ImageForm,
10+
type ImageFormProps,
11+
type ImageFormSubmitParams,
12+
} from '../../../../forms/ImageForm';
913
import {i18n} from '../../../../i18n/widgets';
1014
import {useBooleanState, useElementState} from '../../../../react-utils/hooks';
1115

@@ -14,29 +18,59 @@ import './view.scss';
1418
const b = cn('image-placeholder');
1519
const placement: PopupPlacement = ['bottom-start', 'top-start', 'bottom-end', 'top-end'];
1620

17-
export type FilePlaceholderProps = {
21+
export type RenderImageWidgetFormProps = {
22+
/** Handler for submitting form */
23+
onSubmit: (params: ImageFormSubmitParams) => void;
24+
/** Handler for cancellation */
25+
onCancel: () => void;
26+
/** Handler for attach file from device */
27+
onAttach?: (files: File[]) => void;
28+
/** Uploading attached file */
29+
uploading?: boolean;
30+
};
31+
export type RenderImageWidgetFormFn = (props: RenderImageWidgetFormProps) => React.ReactNode;
32+
33+
const defaultFormRenderer: RenderImageWidgetFormFn = (props) => {
34+
return (
35+
<ImageForm
36+
autoFocus
37+
loading={props.uploading}
38+
onCancel={props.onCancel}
39+
onSubmit={props.onSubmit}
40+
onAttach={props.onAttach}
41+
/>
42+
);
43+
};
44+
45+
export type ImagePlaceholderProps = {
1846
onCancel: () => void;
1947
onSubmit: ImageFormProps['onSubmit'];
2048
onAttach?: (files: File[]) => Promise<void>;
49+
renderForm?: RenderImageWidgetFormFn;
2150
};
2251

23-
export const FilePlaceholder: React.FC<FilePlaceholderProps> = ({onCancel, onSubmit, onAttach}) => {
52+
export const ImagePlaceholder: React.FC<ImagePlaceholderProps> = ({
53+
onCancel,
54+
onSubmit,
55+
onAttach,
56+
renderForm,
57+
}) => {
2458
const isMounted = useMountedState();
25-
const [loading, showLoading, hideLoading] = useBooleanState(false);
59+
const [uploading, startUploading, stopUploading] = useBooleanState(false);
2660
const [anchor, setAnchor] = useElementState();
2761
const attachHandler = useCallback<NonNullable<ImageFormProps['onAttach']>>(
2862
(files) => {
2963
if (!onAttach) return;
3064
if (isMounted()) {
31-
showLoading();
65+
startUploading();
3266
onAttach(files).finally(() => {
3367
if (isMounted()) {
34-
hideLoading();
68+
stopUploading();
3569
}
3670
});
3771
}
3872
},
39-
[isMounted, onAttach, showLoading, hideLoading],
73+
[isMounted, onAttach, startUploading, stopUploading],
4074
);
4175

4276
return (
@@ -46,13 +80,12 @@ export const FilePlaceholder: React.FC<FilePlaceholderProps> = ({onCancel, onSub
4680
{i18n('image')}
4781
</div>
4882
<Popup open modal onOpenChange={onCancel} anchorElement={anchor} placement={placement}>
49-
<ImageForm
50-
autoFocus
51-
loading={loading}
52-
onCancel={onCancel}
53-
onSubmit={onSubmit}
54-
onAttach={onAttach && attachHandler}
55-
/>
83+
{(renderForm || defaultFormRenderer)({
84+
onCancel,
85+
onSubmit,
86+
uploading,
87+
onAttach: onAttach && attachHandler,
88+
})}
5689
</Popup>
5790
</>
5891
);

src/extensions/yfm/ImgSize/ImageWidget/widget.tsx

+14-5
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {imageType, normalizeUrlFactory} from '../../../markdown';
1010
import {ImgSizeAttr} from '../../../specs';
1111
import {ImagesUploadProcess} from '../ImagePaste/upload';
1212

13-
import {FilePlaceholder, type FilePlaceholderProps} from './view';
13+
import {ImagePlaceholder, type ImagePlaceholderProps, type RenderImageWidgetFormFn} from './view';
1414

1515
export const addWidget = (
1616
tr: Transaction,
@@ -26,6 +26,7 @@ export type ImageWidgetDescriptorOpts = {
2626
needToSetDimensionsForUploadedImages: boolean;
2727
uploadImages?: FileUploadHandler;
2828
enableNewImageSizeCalculation?: boolean;
29+
renderImageForm?: RenderImageWidgetFormFn;
2930
};
3031

3132
class ImageWidgetDescriptor extends ReactWidgetDescriptor {
@@ -34,6 +35,7 @@ class ImageWidgetDescriptor extends ReactWidgetDescriptor {
3435
private readonly uploadImages;
3536
private readonly needToSetDimensionsForUploadedImages: boolean;
3637
private readonly enableNewImageSizeCalculation?: boolean;
38+
private readonly renderImageForm: RenderImageWidgetFormFn | undefined;
3739

3840
private widgetHandler: ImageWidgetHandler | null = null;
3941

@@ -42,6 +44,7 @@ class ImageWidgetDescriptor extends ReactWidgetDescriptor {
4244
this.domElem = document.createElement('span');
4345
this.deps = deps;
4446
this.uploadImages = opts.uploadImages;
47+
this.renderImageForm = opts.renderImageForm;
4548
this.needToSetDimensionsForUploadedImages = opts.needToSetDimensionsForUploadedImages;
4649
this.enableNewImageSizeCalculation = opts.enableNewImageSizeCalculation;
4750
}
@@ -54,6 +57,7 @@ class ImageWidgetDescriptor extends ReactWidgetDescriptor {
5457
getPos,
5558
decoId: this.id,
5659
uploadImages: this.uploadImages,
60+
renderImageForm: this.renderImageForm,
5761
needToSetDimensionsForUploadedImages: this.needToSetDimensionsForUploadedImages,
5862
enableNewImageSizeCalculation: this.enableNewImageSizeCalculation,
5963
},
@@ -83,6 +87,7 @@ type ImageWidgetHandlerProps = {
8387
view: EditorView;
8488
getPos: () => number;
8589
uploadImages?: FileUploadHandler;
90+
renderImageForm?: RenderImageWidgetFormFn;
8691
needToSetDimensionsForUploadedImages: boolean;
8792
enableNewImageSizeCalculation?: boolean;
8893
};
@@ -96,6 +101,7 @@ class ImageWidgetHandler {
96101
private readonly normalizeUrl;
97102
private readonly needToSetDimensionsForUploadedImages: boolean;
98103
private readonly enableNewImageSizeCalculation?: boolean;
104+
private readonly renderImageForm: RenderImageWidgetFormFn | undefined;
99105

100106
private cancelled = false;
101107

@@ -105,6 +111,7 @@ class ImageWidgetHandler {
105111
view,
106112
getPos,
107113
uploadImages,
114+
renderImageForm,
108115
needToSetDimensionsForUploadedImages,
109116
enableNewImageSizeCalculation,
110117
}: ImageWidgetHandlerProps,
@@ -115,6 +122,7 @@ class ImageWidgetHandler {
115122
this.getPos = getPos;
116123
this.uploadImages = uploadImages;
117124
this.normalizeUrl = normalizeUrlFactory(deps);
125+
this.renderImageForm = renderImageForm;
118126
this.needToSetDimensionsForUploadedImages = needToSetDimensionsForUploadedImages;
119127
this.enableNewImageSizeCalculation = enableNewImageSizeCalculation;
120128
}
@@ -127,21 +135,22 @@ class ImageWidgetHandler {
127135
this.view = view;
128136
this.getPos = getPos;
129137
return (
130-
<FilePlaceholder
138+
<ImagePlaceholder
131139
onCancel={this.onCancel}
132140
onSubmit={this.onSubmit}
133141
onAttach={this.uploadImages && this.onAttach}
142+
renderForm={this.renderImageForm}
134143
/>
135144
);
136145
}
137146

138-
private onCancel: FilePlaceholderProps['onCancel'] = () => {
147+
private onCancel: ImagePlaceholderProps['onCancel'] = () => {
139148
this.cancelled = true;
140149
this.view.dispatch(removeDecoration(this.view.state.tr, this.decoId));
141150
this.view.focus();
142151
};
143152

144-
private onSubmit: FilePlaceholderProps['onSubmit'] = (params) => {
153+
private onSubmit: ImagePlaceholderProps['onSubmit'] = (params) => {
145154
if (this.cancelled) return;
146155

147156
const url = this.normalizeUrl(params.url)?.url;
@@ -159,7 +168,7 @@ class ImageWidgetHandler {
159168
this.insertNodes([node]);
160169
};
161170

162-
private onAttach: FilePlaceholderProps['onAttach'] = async (files) => {
171+
private onAttach: ImagePlaceholderProps['onAttach'] = async (files) => {
163172
if (this.cancelled || !this.uploadImages) return;
164173

165174
const {view} = this;

src/extensions/yfm/ImgSize/index.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type {Action, ExtensionAuto} from '../../../core';
22

33
import {ImagePaste, type ImagePasteOptions} from './ImagePaste';
4-
import {ImageWidget} from './ImageWidget';
4+
import {ImageWidget, type ImageWidgetOptions} from './ImageWidget';
55
import {ImgSizeSpecs, type ImgSizeSpecsOptions} from './ImgSizeSpecs';
66
import {type AddImageAttrs, addImage} from './actions';
77
import {addImageAction} from './const';
@@ -17,13 +17,15 @@ export type ImgSizeOptions = ImgSizeSpecsOptions & {
1717
} & Pick<
1818
ImagePasteOptions,
1919
'imageUploadHandler' | 'parseInsertedUrlAsImage' | 'enableNewImageSizeCalculation'
20-
>;
20+
> &
21+
Pick<ImageWidgetOptions, 'renderImageWidgetForm'>;
2122

2223
export const ImgSize: ExtensionAuto<ImgSizeOptions> = (builder, opts) => {
2324
builder.use(ImgSizeSpecs, opts);
2425

2526
builder.use(ImageWidget, {
2627
imageUploadHandler: opts.imageUploadHandler,
28+
renderImageWidgetForm: opts.renderImageWidgetForm,
2729
needToSetDimensionsForUploadedImages: Boolean(opts.needToSetDimensionsForUploadedImages),
2830
enableNewImageSizeCalculation: Boolean(opts.enableNewImageSizeCalculation),
2931
});

0 commit comments

Comments
 (0)