Skip to content

Commit 64d389e

Browse files
authored
feat(Image): convert pasted image urls to images (#464)
Co-authored-by: kseniyakuzina <[email protected]>
1 parent 0a16c61 commit 64d389e

File tree

9 files changed

+150
-15
lines changed

9 files changed

+150
-15
lines changed

demo/playground/Playground.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {block} from '../cn';
3838
import {plugins} from '../constants/md-plugins';
3939
import {randomDelay} from '../delay';
4040
import useYfmHtmlBlockStyles from '../hooks/useYfmHtmlBlockStyles';
41+
import {parseInsertedUrlAsImage} from '../utils/imageUrl';
4142
import {debouncedUpdateLocation as updateLocation} from '../utils/location';
4243

4344
import './Playground.scss';
@@ -180,6 +181,9 @@ export const Playground = React.memo<PlaygroundProps>((props) => {
180181
: undefined,
181182
extensionOptions: {
182183
commandMenu: {actions: wysiwygCommandMenuConfig ?? wCommandMenuConfig},
184+
imgSize: {
185+
parseInsertedUrlAsImage,
186+
},
183187
...extensionOptions,
184188
},
185189
markupConfig: {

demo/presets/PresetDemo.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {SplitModePreview} from '../SplitModePreview';
2020
import {block} from '../cn';
2121
import {plugins} from '../constants/md-plugins';
2222
import {randomDelay} from '../delay';
23+
import {parseInsertedUrlAsImage} from '../utils/imageUrl';
2324

2425
import '../playground/Playground.scss';
2526

@@ -89,6 +90,13 @@ export const PresetDemo = React.memo<PresetDemoProps>((props) => {
8990
splitMode: splitModeOrientation,
9091
renderPreview,
9192
fileUploadHandler,
93+
wysiwygConfig: {
94+
extensionOptions: {
95+
imgSize: {
96+
parseInsertedUrlAsImage,
97+
},
98+
},
99+
},
92100
});
93101

94102
useEffect(() => {

demo/utils/imageUrl.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
const knownImageHostsRegexString = '(jing|avatars)';
2+
3+
const supportedImageExtensionsRegexString = '\\.(jpe?g|png|svgz?|gif|webp)';
4+
5+
export const imageUrlRegex = new RegExp(
6+
`^https?:\\/\\/(\\S*?${supportedImageExtensionsRegexString}|${knownImageHostsRegexString}\\S+)$`,
7+
);
8+
9+
export const imageNameRegex = new RegExp(`\\/([^/]*?)(${supportedImageExtensionsRegexString})?$`);
10+
11+
export const parseInsertedUrlAsImage = (text: string) =>
12+
imageUrlRegex.test(text) ? {imageUrl: text, title: text.match(imageNameRegex)?.[1]} : null;

src/bundle/wysiwyg-preset.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,9 @@ export const BundlePreset: ExtensionAuto<BundlePresetOptions> = (builder, opts)
8383
ulInputRules: {plus: false},
8484
...opts.lists,
8585
},
86+
image: {
87+
parseInsertedUrlAsImage: opts.imgSize?.parseInsertedUrlAsImage,
88+
},
8689
};
8790
const defaultOptions: BehaviorPresetOptions & DefaultPresetOptions = {
8891
...commonMarkOptions,
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import {Plugin} from 'prosemirror-state';
2+
3+
import {ExtensionAuto} from '../../../../core';
4+
import {DataTransferType} from '../../../behavior/Clipboard/utils';
5+
import {imageType} from '../ImageSpecs';
6+
7+
export type ImageUrlPasteOptions = {
8+
/**
9+
* The function, used to determine if the pasted text is the image url and should be inserted as an image
10+
*/
11+
parseInsertedUrlAsImage?: (text: string) => {imageUrl: string; title?: string} | null;
12+
};
13+
14+
export const imageUrlPaste: ExtensionAuto<ImageUrlPasteOptions> = (builder, opts) => {
15+
builder.addPlugin(
16+
() =>
17+
new Plugin({
18+
props: {
19+
handleDOMEvents: {
20+
paste(view, e) {
21+
if (
22+
!opts.parseInsertedUrlAsImage ||
23+
!e.clipboardData ||
24+
view.state.selection.$from.parent.type.spec.code
25+
)
26+
return false;
27+
28+
const {imageUrl, title} =
29+
opts.parseInsertedUrlAsImage(
30+
e.clipboardData.getData(DataTransferType.Text) ?? '',
31+
) || {};
32+
33+
if (!imageUrl) {
34+
return false;
35+
}
36+
37+
e.preventDefault();
38+
39+
const imageNode = imageType(view.state.schema).create({
40+
src: imageUrl,
41+
alt: title,
42+
});
43+
44+
const tr = view.state.tr.replaceSelectionWith(imageNode);
45+
view.dispatch(tr.scrollIntoView());
46+
47+
return true;
48+
},
49+
},
50+
},
51+
}),
52+
builder.Priority.High,
53+
);
54+
};

src/extensions/markdown/Image/index.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,28 @@
11
import type {Action, ExtensionAuto} from '../../../core';
2+
import {isFunction} from '../../../lodash';
23

34
import {ImageSpecs, imageType} from './ImageSpecs';
45
import {AddImageAttrs, addImage} from './actions';
56
import {addImageAction} from './const';
7+
import {ImageUrlPasteOptions, imageUrlPaste} from './imageUrlPaste';
68

79
export {imageNodeName, imageType, ImageAttr} from './ImageSpecs';
810
/** @deprecated Use `imageType` instead */
911
export const imgType = imageType;
1012
export type {AddImageAttrs} from './actions';
1113

12-
export const Image: ExtensionAuto = (builder) => {
14+
export type ImageOptions = ImageUrlPasteOptions;
15+
16+
export const Image: ExtensionAuto<ImageOptions | undefined> = (builder, opts) => {
1317
builder.use(ImageSpecs);
1418

1519
builder.addAction(addImageAction, ({schema}) => addImage(schema));
20+
21+
if (isFunction(opts?.parseInsertedUrlAsImage)) {
22+
builder.use(imageUrlPaste, {
23+
parseInsertedUrlAsImage: opts.parseInsertedUrlAsImage,
24+
});
25+
}
1626
};
1727

1828
declare global {

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

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {ExtensionAuto} from '../../../../core';
77
import {isFunction} from '../../../../lodash';
88
import {FileUploadHandler} from '../../../../utils/upload';
99
import {clipboardUtils} from '../../../behavior/Clipboard';
10+
import {DataTransferType} from '../../../behavior/Clipboard/utils';
1011
import {ImageAttr, ImgSizeAttr, imageType} from '../../../specs';
1112
import {CreateImageNodeOptions, isImageNode} from '../utils';
1213

@@ -15,12 +16,22 @@ import {ImagesUploadProcess} from './upload';
1516
const {isFilesFromHtml, isFilesOnly, isImageFile} = clipboardUtils;
1617

1718
export type ImagePasteOptions = Pick<CreateImageNodeOptions, 'needDimmensions'> & {
18-
imageUploadHandler: FileUploadHandler;
19+
imageUploadHandler?: FileUploadHandler;
20+
/**
21+
* The function, used to determine if the pasted text is the image url and should be inserted as an image
22+
*/
23+
parseInsertedUrlAsImage?: (text: string) => {imageUrl: string; title?: string} | null;
1924
};
2025

2126
export const ImagePaste: ExtensionAuto<ImagePasteOptions> = (builder, opts) => {
22-
if (!opts || !isFunction(opts.imageUploadHandler))
23-
throw new Error('ImagePaste extension: imageUploadHandler is not a function');
27+
const {parseInsertedUrlAsImage, imageUploadHandler} = opts ?? {};
28+
29+
if (!isFunction(imageUploadHandler ?? parseInsertedUrlAsImage))
30+
throw new Error(
31+
`ImagePaste extension: ${
32+
opts.imageUploadHandler ? 'imageUploadHandler' : 'parseInsertedUrlAsImage'
33+
} is not a function`,
34+
);
2435

2536
builder.addPlugin(
2637
() =>
@@ -29,20 +40,46 @@ export const ImagePaste: ExtensionAuto<ImagePasteOptions> = (builder, opts) => {
2940
handleDOMEvents: {
3041
paste(view, e) {
3142
const files = getPastedImages(e.clipboardData);
32-
if (files) {
43+
if (imageUploadHandler && files) {
3344
e.preventDefault();
3445
new ImagesUploadProcess(
3546
view,
3647
files,
37-
opts.imageUploadHandler,
48+
imageUploadHandler,
3849
view.state.tr.selection.from,
3950
opts,
4051
).run();
52+
return true;
53+
} else if (parseInsertedUrlAsImage) {
54+
const {imageUrl, title} =
55+
parseInsertedUrlAsImage(
56+
e.clipboardData?.getData(DataTransferType.Text) ?? '',
57+
) || {};
58+
59+
if (!imageUrl) {
60+
return false;
61+
}
62+
63+
e.preventDefault();
64+
65+
const imageNode = imageType(view.state.schema).create({
66+
src: imageUrl,
67+
alt: title,
68+
});
69+
70+
const tr = view.state.tr.replaceSelectionWith(imageNode);
71+
view.dispatch(tr.scrollIntoView());
72+
4173
return true;
4274
}
75+
4376
return false;
4477
},
4578
drop(view, e) {
79+
if (!imageUploadHandler) {
80+
return false;
81+
}
82+
4683
// handle drop images from device
4784
if (view.dragging) return false;
4885

@@ -63,7 +100,7 @@ export const ImagePaste: ExtensionAuto<ImagePasteOptions> = (builder, opts) => {
63100
new ImagesUploadProcess(
64101
view,
65102
files,
66-
opts.imageUploadHandler,
103+
imageUploadHandler,
67104
posToInsert,
68105
opts,
69106
).run();
@@ -74,6 +111,10 @@ export const ImagePaste: ExtensionAuto<ImagePasteOptions> = (builder, opts) => {
74111
},
75112
},
76113
handlePaste(view, _event, slice) {
114+
if (!imageUploadHandler) {
115+
return false;
116+
}
117+
77118
const node = sliceSingleNode(slice);
78119
if (node && isImageNode(node)) {
79120
const imgUrl = node.attrs[ImgSizeAttr.Src];
@@ -82,7 +123,7 @@ export const ImagePaste: ExtensionAuto<ImagePasteOptions> = (builder, opts) => {
82123
new ImagesUploadProcess(
83124
view,
84125
[imgFile],
85-
opts.imageUploadHandler,
126+
imageUploadHandler,
86127
view.state.tr.selection.from,
87128
opts,
88129
).run();

src/extensions/yfm/ImgSize/index.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,20 @@
11
import type {Action, ExtensionAuto} from '../../../core';
2-
import type {FileUploadHandler} from '../../../utils';
32

4-
import {ImagePaste} from './ImagePaste';
3+
import {ImagePaste, ImagePasteOptions} from './ImagePaste';
54
import {ImageWidget} from './ImageWidget';
65
import {ImgSizeSpecs, ImgSizeSpecsOptions} from './ImgSizeSpecs';
76
import {AddImageAttrs, addImage} from './actions';
87
import {addImageAction} from './const';
98
import {imgSizeNodeViewPlugin} from './plugins/ImgSizeNodeView';
109

1110
export type ImgSizeOptions = ImgSizeSpecsOptions & {
12-
imageUploadHandler?: FileUploadHandler;
1311
/**
1412
* If we need to set dimensions for uploaded images
1513
*
1614
* @default false
1715
*/
1816
needToSetDimensionsForUploadedImages?: boolean;
19-
};
17+
} & Pick<ImagePasteOptions, 'imageUploadHandler' | 'parseInsertedUrlAsImage'>;
2018

2119
export const ImgSize: ExtensionAuto<ImgSizeOptions> = (builder, opts) => {
2220
builder.use(ImgSizeSpecs, opts);
@@ -26,10 +24,11 @@ export const ImgSize: ExtensionAuto<ImgSizeOptions> = (builder, opts) => {
2624
needToSetDimensionsForUploadedImages: Boolean(opts.needToSetDimensionsForUploadedImages),
2725
});
2826

29-
if (opts.imageUploadHandler) {
27+
if (opts.imageUploadHandler || opts.parseInsertedUrlAsImage) {
3028
builder.use(ImagePaste, {
3129
imageUploadHandler: opts.imageUploadHandler,
3230
needDimmensions: Boolean(opts.needToSetDimensionsForUploadedImages),
31+
parseInsertedUrlAsImage: opts.parseInsertedUrlAsImage,
3332
});
3433
}
3534

src/presets/commonmark.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
HorizontalRule,
1616
Html,
1717
Image,
18+
ImageOptions,
1819
Italic,
1920
ItalicOptions,
2021
Link,
@@ -33,7 +34,7 @@ export type CommonMarkPresetOptions = ZeroPresetOptions & {
3334
lists?: ListsOptions;
3435
italic?: ItalicOptions;
3536
breaks?: BreaksOptions;
36-
image?: false | Extension;
37+
image?: false | Extension | ImageOptions;
3738
codeBlock?: CodeBlockOptions;
3839
blockquote?: BlockquoteOptions;
3940
heading?: false | Extension | HeadingOptions;
@@ -55,7 +56,10 @@ export const CommonMarkPreset: ExtensionAuto<CommonMarkPresetOptions> = (builder
5556
.use(Blockquote, opts.blockquote ?? {});
5657

5758
if (opts.image !== false) {
58-
builder.use(isFunction(opts.image) ? opts.image : Image);
59+
builder.use(
60+
isFunction(opts.image) ? opts.image : Image,
61+
isFunction(opts.image) ? undefined : opts.image,
62+
);
5963
}
6064

6165
if (opts.heading !== false) {

0 commit comments

Comments
 (0)