From 64d389e592109db642daeabee5a086566ee320ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D1=81=D0=B5=D0=BD=D0=B8=D1=8F?= <31247233+kseniya57@users.noreply.github.com> Date: Mon, 11 Nov 2024 16:27:12 +0300 Subject: [PATCH] feat(Image): convert pasted image urls to images (#464) Co-authored-by: kseniyakuzina --- demo/playground/Playground.tsx | 4 ++ demo/presets/PresetDemo.tsx | 8 +++ demo/utils/imageUrl.ts | 12 ++++ src/bundle/wysiwyg-preset.ts | 3 + .../markdown/Image/imageUrlPaste/index.ts | 54 ++++++++++++++++++ src/extensions/markdown/Image/index.ts | 12 +++- .../yfm/ImgSize/ImagePaste/index.ts | 55 ++++++++++++++++--- src/extensions/yfm/ImgSize/index.ts | 9 ++- src/presets/commonmark.ts | 8 ++- 9 files changed, 150 insertions(+), 15 deletions(-) create mode 100644 demo/utils/imageUrl.ts create mode 100644 src/extensions/markdown/Image/imageUrlPaste/index.ts diff --git a/demo/playground/Playground.tsx b/demo/playground/Playground.tsx index c2a67659..4a63dd64 100644 --- a/demo/playground/Playground.tsx +++ b/demo/playground/Playground.tsx @@ -38,6 +38,7 @@ import {block} from '../cn'; import {plugins} from '../constants/md-plugins'; import {randomDelay} from '../delay'; import useYfmHtmlBlockStyles from '../hooks/useYfmHtmlBlockStyles'; +import {parseInsertedUrlAsImage} from '../utils/imageUrl'; import {debouncedUpdateLocation as updateLocation} from '../utils/location'; import './Playground.scss'; @@ -180,6 +181,9 @@ export const Playground = React.memo((props) => { : undefined, extensionOptions: { commandMenu: {actions: wysiwygCommandMenuConfig ?? wCommandMenuConfig}, + imgSize: { + parseInsertedUrlAsImage, + }, ...extensionOptions, }, markupConfig: { diff --git a/demo/presets/PresetDemo.tsx b/demo/presets/PresetDemo.tsx index 3c4de9a2..8cac7ef5 100644 --- a/demo/presets/PresetDemo.tsx +++ b/demo/presets/PresetDemo.tsx @@ -20,6 +20,7 @@ import {SplitModePreview} from '../SplitModePreview'; import {block} from '../cn'; import {plugins} from '../constants/md-plugins'; import {randomDelay} from '../delay'; +import {parseInsertedUrlAsImage} from '../utils/imageUrl'; import '../playground/Playground.scss'; @@ -89,6 +90,13 @@ export const PresetDemo = React.memo((props) => { splitMode: splitModeOrientation, renderPreview, fileUploadHandler, + wysiwygConfig: { + extensionOptions: { + imgSize: { + parseInsertedUrlAsImage, + }, + }, + }, }); useEffect(() => { diff --git a/demo/utils/imageUrl.ts b/demo/utils/imageUrl.ts new file mode 100644 index 00000000..c4293ce9 --- /dev/null +++ b/demo/utils/imageUrl.ts @@ -0,0 +1,12 @@ +const knownImageHostsRegexString = '(jing|avatars)'; + +const supportedImageExtensionsRegexString = '\\.(jpe?g|png|svgz?|gif|webp)'; + +export const imageUrlRegex = new RegExp( + `^https?:\\/\\/(\\S*?${supportedImageExtensionsRegexString}|${knownImageHostsRegexString}\\S+)$`, +); + +export const imageNameRegex = new RegExp(`\\/([^/]*?)(${supportedImageExtensionsRegexString})?$`); + +export const parseInsertedUrlAsImage = (text: string) => + imageUrlRegex.test(text) ? {imageUrl: text, title: text.match(imageNameRegex)?.[1]} : null; diff --git a/src/bundle/wysiwyg-preset.ts b/src/bundle/wysiwyg-preset.ts index be410734..89b29a93 100644 --- a/src/bundle/wysiwyg-preset.ts +++ b/src/bundle/wysiwyg-preset.ts @@ -83,6 +83,9 @@ export const BundlePreset: ExtensionAuto = (builder, opts) ulInputRules: {plus: false}, ...opts.lists, }, + image: { + parseInsertedUrlAsImage: opts.imgSize?.parseInsertedUrlAsImage, + }, }; const defaultOptions: BehaviorPresetOptions & DefaultPresetOptions = { ...commonMarkOptions, diff --git a/src/extensions/markdown/Image/imageUrlPaste/index.ts b/src/extensions/markdown/Image/imageUrlPaste/index.ts new file mode 100644 index 00000000..ca649335 --- /dev/null +++ b/src/extensions/markdown/Image/imageUrlPaste/index.ts @@ -0,0 +1,54 @@ +import {Plugin} from 'prosemirror-state'; + +import {ExtensionAuto} from '../../../../core'; +import {DataTransferType} from '../../../behavior/Clipboard/utils'; +import {imageType} from '../ImageSpecs'; + +export type ImageUrlPasteOptions = { + /** + * The function, used to determine if the pasted text is the image url and should be inserted as an image + */ + parseInsertedUrlAsImage?: (text: string) => {imageUrl: string; title?: string} | null; +}; + +export const imageUrlPaste: ExtensionAuto = (builder, opts) => { + builder.addPlugin( + () => + new Plugin({ + props: { + handleDOMEvents: { + paste(view, e) { + if ( + !opts.parseInsertedUrlAsImage || + !e.clipboardData || + view.state.selection.$from.parent.type.spec.code + ) + return false; + + const {imageUrl, title} = + opts.parseInsertedUrlAsImage( + e.clipboardData.getData(DataTransferType.Text) ?? '', + ) || {}; + + if (!imageUrl) { + return false; + } + + e.preventDefault(); + + const imageNode = imageType(view.state.schema).create({ + src: imageUrl, + alt: title, + }); + + const tr = view.state.tr.replaceSelectionWith(imageNode); + view.dispatch(tr.scrollIntoView()); + + return true; + }, + }, + }, + }), + builder.Priority.High, + ); +}; diff --git a/src/extensions/markdown/Image/index.ts b/src/extensions/markdown/Image/index.ts index 57f66c6a..78e7ef27 100644 --- a/src/extensions/markdown/Image/index.ts +++ b/src/extensions/markdown/Image/index.ts @@ -1,18 +1,28 @@ import type {Action, ExtensionAuto} from '../../../core'; +import {isFunction} from '../../../lodash'; import {ImageSpecs, imageType} from './ImageSpecs'; import {AddImageAttrs, addImage} from './actions'; import {addImageAction} from './const'; +import {ImageUrlPasteOptions, imageUrlPaste} from './imageUrlPaste'; export {imageNodeName, imageType, ImageAttr} from './ImageSpecs'; /** @deprecated Use `imageType` instead */ export const imgType = imageType; export type {AddImageAttrs} from './actions'; -export const Image: ExtensionAuto = (builder) => { +export type ImageOptions = ImageUrlPasteOptions; + +export const Image: ExtensionAuto = (builder, opts) => { builder.use(ImageSpecs); builder.addAction(addImageAction, ({schema}) => addImage(schema)); + + if (isFunction(opts?.parseInsertedUrlAsImage)) { + builder.use(imageUrlPaste, { + parseInsertedUrlAsImage: opts.parseInsertedUrlAsImage, + }); + } }; declare global { diff --git a/src/extensions/yfm/ImgSize/ImagePaste/index.ts b/src/extensions/yfm/ImgSize/ImagePaste/index.ts index e6b66913..0627f2f6 100644 --- a/src/extensions/yfm/ImgSize/ImagePaste/index.ts +++ b/src/extensions/yfm/ImgSize/ImagePaste/index.ts @@ -7,6 +7,7 @@ import {ExtensionAuto} from '../../../../core'; import {isFunction} from '../../../../lodash'; import {FileUploadHandler} from '../../../../utils/upload'; import {clipboardUtils} from '../../../behavior/Clipboard'; +import {DataTransferType} from '../../../behavior/Clipboard/utils'; import {ImageAttr, ImgSizeAttr, imageType} from '../../../specs'; import {CreateImageNodeOptions, isImageNode} from '../utils'; @@ -15,12 +16,22 @@ import {ImagesUploadProcess} from './upload'; const {isFilesFromHtml, isFilesOnly, isImageFile} = clipboardUtils; export type ImagePasteOptions = Pick & { - imageUploadHandler: FileUploadHandler; + imageUploadHandler?: FileUploadHandler; + /** + * The function, used to determine if the pasted text is the image url and should be inserted as an image + */ + parseInsertedUrlAsImage?: (text: string) => {imageUrl: string; title?: string} | null; }; export const ImagePaste: ExtensionAuto = (builder, opts) => { - if (!opts || !isFunction(opts.imageUploadHandler)) - throw new Error('ImagePaste extension: imageUploadHandler is not a function'); + const {parseInsertedUrlAsImage, imageUploadHandler} = opts ?? {}; + + if (!isFunction(imageUploadHandler ?? parseInsertedUrlAsImage)) + throw new Error( + `ImagePaste extension: ${ + opts.imageUploadHandler ? 'imageUploadHandler' : 'parseInsertedUrlAsImage' + } is not a function`, + ); builder.addPlugin( () => @@ -29,20 +40,46 @@ export const ImagePaste: ExtensionAuto = (builder, opts) => { handleDOMEvents: { paste(view, e) { const files = getPastedImages(e.clipboardData); - if (files) { + if (imageUploadHandler && files) { e.preventDefault(); new ImagesUploadProcess( view, files, - opts.imageUploadHandler, + imageUploadHandler, view.state.tr.selection.from, opts, ).run(); + return true; + } else if (parseInsertedUrlAsImage) { + const {imageUrl, title} = + parseInsertedUrlAsImage( + e.clipboardData?.getData(DataTransferType.Text) ?? '', + ) || {}; + + if (!imageUrl) { + return false; + } + + e.preventDefault(); + + const imageNode = imageType(view.state.schema).create({ + src: imageUrl, + alt: title, + }); + + const tr = view.state.tr.replaceSelectionWith(imageNode); + view.dispatch(tr.scrollIntoView()); + return true; } + return false; }, drop(view, e) { + if (!imageUploadHandler) { + return false; + } + // handle drop images from device if (view.dragging) return false; @@ -63,7 +100,7 @@ export const ImagePaste: ExtensionAuto = (builder, opts) => { new ImagesUploadProcess( view, files, - opts.imageUploadHandler, + imageUploadHandler, posToInsert, opts, ).run(); @@ -74,6 +111,10 @@ export const ImagePaste: ExtensionAuto = (builder, opts) => { }, }, handlePaste(view, _event, slice) { + if (!imageUploadHandler) { + return false; + } + const node = sliceSingleNode(slice); if (node && isImageNode(node)) { const imgUrl = node.attrs[ImgSizeAttr.Src]; @@ -82,7 +123,7 @@ export const ImagePaste: ExtensionAuto = (builder, opts) => { new ImagesUploadProcess( view, [imgFile], - opts.imageUploadHandler, + imageUploadHandler, view.state.tr.selection.from, opts, ).run(); diff --git a/src/extensions/yfm/ImgSize/index.ts b/src/extensions/yfm/ImgSize/index.ts index 23c8fd84..61bec405 100644 --- a/src/extensions/yfm/ImgSize/index.ts +++ b/src/extensions/yfm/ImgSize/index.ts @@ -1,7 +1,6 @@ import type {Action, ExtensionAuto} from '../../../core'; -import type {FileUploadHandler} from '../../../utils'; -import {ImagePaste} from './ImagePaste'; +import {ImagePaste, ImagePasteOptions} from './ImagePaste'; import {ImageWidget} from './ImageWidget'; import {ImgSizeSpecs, ImgSizeSpecsOptions} from './ImgSizeSpecs'; import {AddImageAttrs, addImage} from './actions'; @@ -9,14 +8,13 @@ import {addImageAction} from './const'; import {imgSizeNodeViewPlugin} from './plugins/ImgSizeNodeView'; export type ImgSizeOptions = ImgSizeSpecsOptions & { - imageUploadHandler?: FileUploadHandler; /** * If we need to set dimensions for uploaded images * * @default false */ needToSetDimensionsForUploadedImages?: boolean; -}; +} & Pick; export const ImgSize: ExtensionAuto = (builder, opts) => { builder.use(ImgSizeSpecs, opts); @@ -26,10 +24,11 @@ export const ImgSize: ExtensionAuto = (builder, opts) => { needToSetDimensionsForUploadedImages: Boolean(opts.needToSetDimensionsForUploadedImages), }); - if (opts.imageUploadHandler) { + if (opts.imageUploadHandler || opts.parseInsertedUrlAsImage) { builder.use(ImagePaste, { imageUploadHandler: opts.imageUploadHandler, needDimmensions: Boolean(opts.needToSetDimensionsForUploadedImages), + parseInsertedUrlAsImage: opts.parseInsertedUrlAsImage, }); } diff --git a/src/presets/commonmark.ts b/src/presets/commonmark.ts index b4333497..dbd2cdac 100644 --- a/src/presets/commonmark.ts +++ b/src/presets/commonmark.ts @@ -15,6 +15,7 @@ import { HorizontalRule, Html, Image, + ImageOptions, Italic, ItalicOptions, Link, @@ -33,7 +34,7 @@ export type CommonMarkPresetOptions = ZeroPresetOptions & { lists?: ListsOptions; italic?: ItalicOptions; breaks?: BreaksOptions; - image?: false | Extension; + image?: false | Extension | ImageOptions; codeBlock?: CodeBlockOptions; blockquote?: BlockquoteOptions; heading?: false | Extension | HeadingOptions; @@ -55,7 +56,10 @@ export const CommonMarkPreset: ExtensionAuto = (builder .use(Blockquote, opts.blockquote ?? {}); if (opts.image !== false) { - builder.use(isFunction(opts.image) ? opts.image : Image); + builder.use( + isFunction(opts.image) ? opts.image : Image, + isFunction(opts.image) ? undefined : opts.image, + ); } if (opts.heading !== false) {