Skip to content

Commit 520fc3c

Browse files
committed
feat(ImgSize): added ability to render custom form in image widget
1 parent 65fd284 commit 520fc3c

File tree

6 files changed

+208
-21
lines changed

6 files changed

+208
-21
lines changed
Lines changed: 13 additions & 0 deletions
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+
};
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import {memo} from 'react';
2+
3+
import {FilePlus} from '@gravity-ui/icons';
4+
import {Card, 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 = 'image' + 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+
<Card
42+
type="action"
43+
{...triggerProps}
44+
style={{
45+
height: 94,
46+
width: 120,
47+
display: 'flex',
48+
alignItems: 'center',
49+
justifyContent: 'center',
50+
}}
51+
>
52+
<Icon data={FilePlus} width={32} height={32} />
53+
</Card>
54+
<input type="file" multiple={false} accept="image/*" {...controlProps} />
55+
</div>
56+
);
57+
});
58+
59+
export const ImageCustomFormDemo = memo(() => {
60+
const editor = useMarkdownEditor({
61+
initial: {
62+
mode: 'wysiwyg',
63+
markup: 'Click the `Image` action on the toolbar or select it from the slash `/` menu.',
64+
},
65+
handlers: {
66+
uploadFile: async (file) => {
67+
await randomDelay(1000, 3000);
68+
return {url: URL.createObjectURL(file)};
69+
},
70+
},
71+
wysiwygConfig: {
72+
extensionOptions: {
73+
imgSize: {
74+
// pass a function to render custom form in the image widget
75+
renderImageWidgetForm: (props) => <ImageForm {...props} />,
76+
},
77+
},
78+
},
79+
});
80+
81+
return (
82+
<PlaygroundLayout
83+
editor={editor}
84+
view={({className}) => (
85+
<MarkdownEditorView
86+
autofocus
87+
stickyToolbar
88+
settingsVisible
89+
editor={editor}
90+
className={className}
91+
/>
92+
)}
93+
/>
94+
);
95+
});
96+
97+
ImageCustomFormDemo.displayName = 'ImageCustomFormDemo';
98+
99+
type ImageItem = {
100+
id: string;
101+
url: string;
102+
};
103+
104+
function getImages(): ImageItem[] {
105+
return [
106+
{
107+
id: '0',
108+
url: 'https://avatars.mds.yandex.net/i?id=a419cb4c92640784cbd8a22ba9a6ca8d_l-8497046-images-thumbs&ref=rim&n=13&w=400&h=400',
109+
},
110+
{
111+
id: '1',
112+
url: 'https://avatars.mds.yandex.net/i?id=c19b7e6a249959038720173229cc2d5c_l-10092505-images-thumbs&ref=rim&n=13&w=400&h=400',
113+
},
114+
{
115+
id: '2',
116+
url: 'https://avatars.mds.yandex.net/i?id=45fa11b663175616d70a934e2fc0e2be_l-4250988-images-thumbs&ref=rim&n=13&w=400&h=400',
117+
},
118+
{
119+
id: '3',
120+
url: 'https://avatars.mds.yandex.net/i?id=8939c4dec1c737c074722f84179a419f8ed7cf90-9644918-images-thumbs&ref=rim&n=33&w=400&h=400',
121+
},
122+
{
123+
id: '4',
124+
url: 'https://avatars.mds.yandex.net/i?id=a257cad730b11469beb8d9d14d7e0438-4077629-images-thumbs&ref=rim&n=33&w=400&h=400',
125+
},
126+
];
127+
}

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

Lines changed: 3 additions & 0 deletions
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'
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
}),
2225
);

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

Lines changed: 47 additions & 14 deletions
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

Lines changed: 14 additions & 5 deletions
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,
@@ -25,13 +25,15 @@ export const removeWidget = removeDecoration;
2525
export type ImageWidgetDescriptorOpts = {
2626
needToSetDimensionsForUploadedImages: boolean;
2727
uploadImages?: FileUploadHandler;
28+
renderImageForm?: RenderImageWidgetFormFn;
2829
};
2930

3031
class ImageWidgetDescriptor extends ReactWidgetDescriptor {
3132
private readonly domElem;
3233
private readonly deps;
3334
private readonly uploadImages;
3435
private readonly needToSetDimensionsForUploadedImages: boolean;
36+
private readonly renderImageForm: RenderImageWidgetFormFn | undefined;
3537

3638
private widgetHandler: ImageWidgetHandler | null = null;
3739

@@ -40,6 +42,7 @@ class ImageWidgetDescriptor extends ReactWidgetDescriptor {
4042
this.domElem = document.createElement('span');
4143
this.deps = deps;
4244
this.uploadImages = opts.uploadImages;
45+
this.renderImageForm = opts.renderImageForm;
4346
this.needToSetDimensionsForUploadedImages = opts.needToSetDimensionsForUploadedImages;
4447
}
4548

@@ -51,6 +54,7 @@ class ImageWidgetDescriptor extends ReactWidgetDescriptor {
5154
getPos,
5255
decoId: this.id,
5356
uploadImages: this.uploadImages,
57+
renderImageForm: this.renderImageForm,
5458
needToSetDimensionsForUploadedImages: this.needToSetDimensionsForUploadedImages,
5559
},
5660
this.deps,
@@ -79,6 +83,7 @@ type ImageWidgetHandlerProps = {
7983
view: EditorView;
8084
getPos: () => number;
8185
uploadImages?: FileUploadHandler;
86+
renderImageForm?: RenderImageWidgetFormFn;
8287
needToSetDimensionsForUploadedImages: boolean;
8388
};
8489

@@ -90,6 +95,7 @@ class ImageWidgetHandler {
9095
private readonly uploadImages;
9196
private readonly normalizeUrl;
9297
private readonly needToSetDimensionsForUploadedImages: boolean;
98+
private readonly renderImageForm: RenderImageWidgetFormFn | undefined;
9399

94100
private cancelled = false;
95101

@@ -99,6 +105,7 @@ class ImageWidgetHandler {
99105
view,
100106
getPos,
101107
uploadImages,
108+
renderImageForm,
102109
needToSetDimensionsForUploadedImages,
103110
}: ImageWidgetHandlerProps,
104111
deps: ExtensionDeps,
@@ -108,6 +115,7 @@ class ImageWidgetHandler {
108115
this.getPos = getPos;
109116
this.uploadImages = uploadImages;
110117
this.normalizeUrl = normalizeUrlFactory(deps);
118+
this.renderImageForm = renderImageForm;
111119
this.needToSetDimensionsForUploadedImages = needToSetDimensionsForUploadedImages;
112120
}
113121

@@ -119,21 +127,22 @@ class ImageWidgetHandler {
119127
this.view = view;
120128
this.getPos = getPos;
121129
return (
122-
<FilePlaceholder
130+
<ImagePlaceholder
123131
onCancel={this.onCancel}
124132
onSubmit={this.onSubmit}
125133
onAttach={this.uploadImages && this.onAttach}
134+
renderForm={this.renderImageForm}
126135
/>
127136
);
128137
}
129138

130-
private onCancel: FilePlaceholderProps['onCancel'] = () => {
139+
private onCancel: ImagePlaceholderProps['onCancel'] = () => {
131140
this.cancelled = true;
132141
this.view.dispatch(removeDecoration(this.view.state.tr, this.decoId));
133142
this.view.focus();
134143
};
135144

136-
private onSubmit: FilePlaceholderProps['onSubmit'] = (params) => {
145+
private onSubmit: ImagePlaceholderProps['onSubmit'] = (params) => {
137146
if (this.cancelled) return;
138147

139148
const url = this.normalizeUrl(params.url)?.url;
@@ -151,7 +160,7 @@ class ImageWidgetHandler {
151160
this.insertNodes([node]);
152161
};
153162

154-
private onAttach: FilePlaceholderProps['onAttach'] = async (files) => {
163+
private onAttach: ImagePlaceholderProps['onAttach'] = async (files) => {
155164
if (this.cancelled || !this.uploadImages) return;
156165

157166
const {view} = this;

src/extensions/yfm/ImgSize/index.ts

Lines changed: 4 additions & 2 deletions
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
});
2931

0 commit comments

Comments
 (0)