Skip to content

Commit

Permalink
feat(Image): convert pasted image urls to images (#464)
Browse files Browse the repository at this point in the history
Co-authored-by: kseniyakuzina <[email protected]>
  • Loading branch information
kseniya57 and kseniyakuzina authored Nov 11, 2024
1 parent 0a16c61 commit 64d389e
Show file tree
Hide file tree
Showing 9 changed files with 150 additions and 15 deletions.
4 changes: 4 additions & 0 deletions demo/playground/Playground.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -180,6 +181,9 @@ export const Playground = React.memo<PlaygroundProps>((props) => {
: undefined,
extensionOptions: {
commandMenu: {actions: wysiwygCommandMenuConfig ?? wCommandMenuConfig},
imgSize: {
parseInsertedUrlAsImage,
},
...extensionOptions,
},
markupConfig: {
Expand Down
8 changes: 8 additions & 0 deletions demo/presets/PresetDemo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -89,6 +90,13 @@ export const PresetDemo = React.memo<PresetDemoProps>((props) => {
splitMode: splitModeOrientation,
renderPreview,
fileUploadHandler,
wysiwygConfig: {
extensionOptions: {
imgSize: {
parseInsertedUrlAsImage,
},
},
},
});

useEffect(() => {
Expand Down
12 changes: 12 additions & 0 deletions demo/utils/imageUrl.ts
Original file line number Diff line number Diff line change
@@ -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;
3 changes: 3 additions & 0 deletions src/bundle/wysiwyg-preset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ export const BundlePreset: ExtensionAuto<BundlePresetOptions> = (builder, opts)
ulInputRules: {plus: false},
...opts.lists,
},
image: {
parseInsertedUrlAsImage: opts.imgSize?.parseInsertedUrlAsImage,
},
};
const defaultOptions: BehaviorPresetOptions & DefaultPresetOptions = {
...commonMarkOptions,
Expand Down
54 changes: 54 additions & 0 deletions src/extensions/markdown/Image/imageUrlPaste/index.ts
Original file line number Diff line number Diff line change
@@ -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<ImageUrlPasteOptions> = (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,
);
};
12 changes: 11 additions & 1 deletion src/extensions/markdown/Image/index.ts
Original file line number Diff line number Diff line change
@@ -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<ImageOptions | undefined> = (builder, opts) => {
builder.use(ImageSpecs);

builder.addAction(addImageAction, ({schema}) => addImage(schema));

if (isFunction(opts?.parseInsertedUrlAsImage)) {
builder.use(imageUrlPaste, {
parseInsertedUrlAsImage: opts.parseInsertedUrlAsImage,
});
}
};

declare global {
Expand Down
55 changes: 48 additions & 7 deletions src/extensions/yfm/ImgSize/ImagePaste/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

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

export type ImagePasteOptions = Pick<CreateImageNodeOptions, 'needDimmensions'> & {
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<ImagePasteOptions> = (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(
() =>
Expand All @@ -29,20 +40,46 @@ export const ImagePaste: ExtensionAuto<ImagePasteOptions> = (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;

Expand All @@ -63,7 +100,7 @@ export const ImagePaste: ExtensionAuto<ImagePasteOptions> = (builder, opts) => {
new ImagesUploadProcess(
view,
files,
opts.imageUploadHandler,
imageUploadHandler,
posToInsert,
opts,
).run();
Expand All @@ -74,6 +111,10 @@ export const ImagePaste: ExtensionAuto<ImagePasteOptions> = (builder, opts) => {
},
},
handlePaste(view, _event, slice) {
if (!imageUploadHandler) {
return false;
}

const node = sliceSingleNode(slice);
if (node && isImageNode(node)) {
const imgUrl = node.attrs[ImgSizeAttr.Src];
Expand All @@ -82,7 +123,7 @@ export const ImagePaste: ExtensionAuto<ImagePasteOptions> = (builder, opts) => {
new ImagesUploadProcess(
view,
[imgFile],
opts.imageUploadHandler,
imageUploadHandler,
view.state.tr.selection.from,
opts,
).run();
Expand Down
9 changes: 4 additions & 5 deletions src/extensions/yfm/ImgSize/index.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,20 @@
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';
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<ImagePasteOptions, 'imageUploadHandler' | 'parseInsertedUrlAsImage'>;

export const ImgSize: ExtensionAuto<ImgSizeOptions> = (builder, opts) => {
builder.use(ImgSizeSpecs, opts);
Expand All @@ -26,10 +24,11 @@ export const ImgSize: ExtensionAuto<ImgSizeOptions> = (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,
});
}

Expand Down
8 changes: 6 additions & 2 deletions src/presets/commonmark.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
HorizontalRule,
Html,
Image,
ImageOptions,
Italic,
ItalicOptions,
Link,
Expand All @@ -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;
Expand All @@ -55,7 +56,10 @@ export const CommonMarkPreset: ExtensionAuto<CommonMarkPresetOptions> = (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) {
Expand Down

0 comments on commit 64d389e

Please sign in to comment.