Skip to content

Commit 698aeb0

Browse files
committed
feat(rte): allow to upload image using external widget
1 parent ade8007 commit 698aeb0

File tree

10 files changed

+110
-89
lines changed

10 files changed

+110
-89
lines changed

packages/pluggableWidgets/rich-text-web/CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
88

99
### Added
1010

11-
- We added support to choose image from entity.
11+
- We added support to choose image from entity using external widget.
1212

1313
## [4.7.0] - 2025-06-02
1414

packages/pluggableWidgets/rich-text-web/src/RichText.editorConfig.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { Properties, hidePropertyIn, hidePropertiesIn } from "@mendix/pluggable-widgets-tools";
2-
import { StructurePreviewProps } from "@mendix/widget-plugin-platform/preview/structure-preview-api";
2+
import {
3+
StructurePreviewProps,
4+
dropzone,
5+
container,
6+
rowLayout
7+
} from "@mendix/widget-plugin-platform/preview/structure-preview-api";
38
import { RichTextPreviewProps } from "typings/RichTextProps";
49
import RichTextPreviewSVGDark from "./assets/rich-text-preview-dark.svg";
510
import RichTextPreviewSVGLight from "./assets/rich-text-preview-light.svg";
@@ -58,16 +63,36 @@ export function getProperties(values: RichTextPreviewProps, defaultProperties: P
5863
if (values.toolbarLocation === "hide") {
5964
hidePropertyIn(defaultProperties, values, "preset");
6065
}
66+
67+
if (values.imageSource === "none" || values.imageSource === null) {
68+
hidePropertiesIn(defaultProperties, values, ["imageSourceContent", "enableDefaultUpload"]);
69+
}
6170
return defaultProperties;
6271
}
6372

6473
export function getPreview(props: RichTextPreviewProps, isDarkMode: boolean): StructurePreviewProps {
6574
const variant = isDarkMode ? RichTextPreviewSVGDark : RichTextPreviewSVGLight;
6675
const doc = decodeURIComponent(variant.replace("data:image/svg+xml,", ""));
6776

68-
return {
69-
type: "Image",
70-
document: props.stringAttribute ? doc.replace("[No attribute selected]", `[${props.stringAttribute}]`) : doc,
71-
height: 150
72-
};
77+
const richTextPreview = container()(
78+
rowLayout({ columnSize: "grow", borders: false })({
79+
type: "Image",
80+
document: props.stringAttribute
81+
? doc.replace("[No attribute selected]", `[${props.stringAttribute}]`)
82+
: doc,
83+
height: 150
84+
})
85+
);
86+
87+
if (props.imageSource) {
88+
richTextPreview.children?.push(
89+
rowLayout({ columnSize: "grow", borders: true, borderWidth: 1, borderRadius: 2 })(
90+
dropzone(
91+
dropzone.placeholder(" image upload placeholder widget"),
92+
dropzone.hideDataSourceHeaderIf(false)
93+
)(props.imageSourceContent)
94+
)
95+
);
96+
}
97+
return richTextPreview;
7398
}

packages/pluggableWidgets/rich-text-web/src/RichText.xml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,14 @@
183183
<caption>Selectable images</caption>
184184
<description />
185185
</property>
186+
<property key="imageSourceContent" type="widgets" required="false">
187+
<caption>Content</caption>
188+
<description>Content of a image uploader</description>
189+
</property>
190+
<property key="enableDefaultUpload" type="boolean" defaultValue="true">
191+
<caption>Enable default upload</caption>
192+
<description />
193+
</property>
186194
</propertyGroup>
187195
</propertyGroup>
188196
<propertyGroup caption="Custom toolbar">

packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.spec.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@ describe("Rich Text", () => {
4444
maxHeight: 0,
4545
minHeight: 75,
4646
OverflowY: "auto",
47-
customFonts: []
47+
customFonts: [],
48+
enableDefaultUpload: true
4849
};
4950
});
5051

packages/pluggableWidgets/rich-text-web/src/components/Editor.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import {
3131
import { useEmbedModal } from "./CustomToolbars/useEmbedModal";
3232
import Dialog from "./ModalDialog/Dialog";
3333

34-
export interface EditorProps extends Pick<RichTextContainerProps, "imageSource"> {
34+
export interface EditorProps extends Pick<RichTextContainerProps, "imageSource" | "imageSourceContent"> {
3535
customFonts: CustomFontsType[];
3636
defaultValue?: string;
3737
onTextChange?: (...args: [delta: Delta, oldContent: Delta, source: EmitterSource]) => void;
@@ -201,6 +201,7 @@ const Editor = forwardRef((props: EditorProps, ref: MutableRefObject<Quill | nul
201201
onOpenChange={open => setShowDialog(open)}
202202
parentNode={modalRef.current?.ownerDocument.body}
203203
imageSource={props.imageSource}
204+
imageSourceContent={props.imageSourceContent}
204205
{...dialogConfig}
205206
></Dialog>
206207
</Fragment>

packages/pluggableWidgets/rich-text-web/src/components/EditorWrapper.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,8 @@ function EditorWrapperInner(props: EditorWrapperProps): ReactElement {
5050
toolbarOptions,
5151
enableStatusBar,
5252
tabIndex,
53-
imageSource
53+
imageSource,
54+
imageSourceContent
5455
} = props;
5556

5657
const globalState = useContext(EditorContext);
@@ -211,6 +212,7 @@ function EditorWrapperInner(props: EditorWrapperProps): ReactElement {
211212
key={`${toolbarId}_${stringAttribute.readOnly}`}
212213
customFonts={props.customFonts}
213214
imageSource={imageSource}
215+
imageSourceContent={imageSourceContent}
214216
/>
215217
</div>
216218
{enableStatusBar && (

packages/pluggableWidgets/rich-text-web/src/components/ModalDialog/Dialog.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,15 @@ export type ChildDialogProps =
4949
| ViewCodeDialogBaseProps
5050
| ImageDialogBaseProps;
5151

52-
export type DialogProps = BaseDialogProps & ChildDialogProps & Pick<RichTextContainerProps, "imageSource">;
52+
export type DialogProps = BaseDialogProps &
53+
ChildDialogProps &
54+
Pick<RichTextContainerProps, "imageSource" | "imageSourceContent" | "enableDefaultUpload">;
5355

5456
/**
5557
* Dialog components that will be shown on toolbar's button
5658
*/
5759
export default function Dialog(props: DialogProps): ReactElement {
58-
const { isOpen, onOpenChange, dialogType, config, imageSource } = props;
60+
const { isOpen, onOpenChange, dialogType, config, imageSource, imageSourceContent, enableDefaultUpload } = props;
5961
const { refs, context } = useFloating({
6062
open: isOpen,
6163
onOpenChange
@@ -95,7 +97,12 @@ export default function Dialog(props: DialogProps): ReactElement {
9597
<ViewCodeDialog {...(config as ViewCodeDialogProps)}></ViewCodeDialog>
9698
</If>
9799
<If condition={dialogType === "image"}>
98-
<ImageDialog imageSource={imageSource} {...(config as ImageDialogProps)}></ImageDialog>
100+
<ImageDialog
101+
imageSource={imageSource}
102+
imageSourceContent={imageSourceContent}
103+
enableDefaultUpload={enableDefaultUpload}
104+
{...(config as ImageDialogProps)}
105+
></ImageDialog>
99106
</If>
100107
</div>
101108
</FloatingFocusManager>

packages/pluggableWidgets/rich-text-web/src/components/ModalDialog/DialogContent.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { If } from "@mendix/widget-plugin-component-kit/If";
12
import classNames from "classnames";
23
import { createElement, Fragment, PropsWithChildren, ReactElement } from "react";
34

@@ -49,10 +50,12 @@ export function FormControl(props: FormControlProps): ReactElement {
4950
const { children, className, label } = props;
5051

5152
return (
52-
<div className={classNames("form-group", className)}>
53-
{label && <label className="control-label col-sm-3">{label}</label>}
54-
<div className={`col-sm-${label ? "9" : "12"}`}> {children}</div>
55-
</div>
53+
<If condition={children !== undefined && children !== null}>
54+
<div className={classNames("form-group", className)}>
55+
{label && <label className="control-label col-sm-3">{label}</label>}
56+
<div className={`col-sm-${label ? "9" : "12"}`}> {children}</div>
57+
</div>
58+
</If>
5659
);
5760
}
5861

packages/pluggableWidgets/rich-text-web/src/components/ModalDialog/ImageDialog.tsx

Lines changed: 41 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,87 +1,37 @@
1-
import { ChangeEvent, createElement, Fragment, ReactElement, useEffect, useRef, useState } from "react";
2-
import { type imageConfigType } from "../../utils/formats";
3-
import { DialogBody, DialogContent, DialogFooter, DialogHeader, FormControl } from "./DialogContent";
4-
import { IMG_MIME_TYPES } from "../CustomToolbars/constants";
5-
import classNames from "classnames";
61
import { If } from "@mendix/widget-plugin-component-kit/If";
2+
import classNames from "classnames";
3+
import { ChangeEvent, createElement, ReactElement, useEffect, useRef, useState } from "react";
74
import { RichTextContainerProps } from "../../../typings/RichTextProps";
8-
import { fetchDocumentUrl, fetchImageThumbnail } from "../../utils/mx-data";
5+
import { type imageConfigType } from "../../utils/formats";
6+
import { IMG_MIME_TYPES } from "../CustomToolbars/constants";
7+
import { DialogBody, DialogContent, DialogFooter, DialogHeader, FormControl } from "./DialogContent";
98

109
type imageListType = {
1110
id: string;
1211
url: string;
1312
thumbnailUrl?: string;
1413
};
1514

16-
export interface ImageDialogProps extends Pick<RichTextContainerProps, "imageSource"> {
15+
interface CustomEvent<T = any> extends Event {
16+
/**
17+
* Returns any custom data event was created with. Typically used for synthetic events.
18+
*/
19+
readonly detail: T;
20+
initCustomEvent(typeArg: string, canBubbleArg: boolean, cancelableArg: boolean, detailArg: T): void;
21+
}
22+
23+
export interface ImageDialogProps extends Pick<RichTextContainerProps, "imageSource" | "imageSourceContent"> {
1724
onSubmit(value: imageConfigType): void;
1825
onClose(): void;
1926
defaultValue?: imageConfigType;
20-
}
21-
22-
export interface EntityImageDialogProps extends ImageDialogProps {
23-
onSelect(image: imageListType): void;
24-
}
25-
26-
function EntityImageDialog(props: EntityImageDialogProps): ReactElement {
27-
const { imageSource, onSelect } = props;
28-
const [images, setImages] = useState<imageListType[]>([]);
29-
30-
useEffect(() => {
31-
if (imageSource && imageSource.items && imageSource.items.length > 0 && imageSource.status === "available") {
32-
const newImages: imageListType[] = imageSource.items.map(item => {
33-
const guid = item.id;
34-
const src = fetchDocumentUrl(item.id);
35-
return {
36-
id: guid,
37-
url: src
38-
};
39-
});
40-
41-
Promise.all(
42-
newImages.map(async image => {
43-
if (image.url) {
44-
const thumbnailUrl = await fetchImageThumbnail(image.url);
45-
console.log("Fetched thumbnail for image:", image.id, thumbnailUrl);
46-
return { ...image, thumbnailUrl };
47-
}
48-
return image;
49-
})
50-
).then(fetchedImages => {
51-
setImages(fetchedImages);
52-
});
53-
}
54-
}, [imageSource, imageSource?.status, imageSource?.items]);
55-
56-
if (!imageSource || imageSource.status !== "available") {
57-
return <div className="mx-text mx-text-error">Image source is not available</div>;
58-
}
59-
60-
return (
61-
<Fragment>
62-
{images.length > 0 ? (
63-
<div className="mx-image-dialog-list">
64-
{images.map(image => (
65-
<div key={image.id} className="mx-image-dialog-item" onClick={() => onSelect(image)}>
66-
<img
67-
src={image.thumbnailUrl || image.url}
68-
alt={image.id}
69-
className="mx-image-dialog-thumbnail"
70-
/>
71-
</div>
72-
))}
73-
</div>
74-
) : (
75-
<div className="mx-text mx-text-error">No images found in the datasource</div>
76-
)}
77-
</Fragment>
78-
);
27+
enableDefaultUpload?: boolean;
7928
}
8029

8130
export default function ImageDialog(props: ImageDialogProps): ReactElement {
82-
const { onClose, defaultValue, onSubmit, imageSource } = props;
31+
const { onClose, defaultValue, onSubmit, imageSource, imageSourceContent, enableDefaultUpload } = props;
8332
const [activeTab, setActiveTab] = useState("general");
8433
const [selectedImageEntity, setSelectedImageEntity] = useState<imageListType>();
34+
const imageUploadElementRef = useRef<HTMLDivElement>(null);
8535
// disable embed tab if it is about modifying current video
8636
const disableEmbed =
8737
(defaultValue?.src && defaultValue.src.length > 0) ||
@@ -117,11 +67,30 @@ export default function ImageDialog(props: ImageDialogProps): ReactElement {
11767
setActiveTab("general");
11868
};
11969

70+
const handleImageSelected = (event: CustomEvent<imageListType>): void => {
71+
const image = event.detail;
72+
onEmbedSelected(image);
73+
};
74+
12075
const onEmbedDeleted = (): void => {
12176
setFormState({ ...formState, entityGuid: undefined, src: undefined });
12277
setSelectedImageEntity(undefined);
12378
};
12479

80+
useEffect(() => {
81+
const imgRef = imageUploadElementRef.current;
82+
83+
// const element = ref.current;
84+
if (imgRef !== null) {
85+
imgRef.addEventListener("imageSelected", handleImageSelected);
86+
}
87+
// element.addEventListener("click", handleClick);
88+
89+
return () => {
90+
imgRef?.removeEventListener("imageSelected", handleImageSelected);
91+
};
92+
}, [imageUploadElementRef.current]);
93+
12594
return (
12695
<DialogContent className="video-dialog">
12796
<DialogHeader onClose={onClose}>{activeTab === "general" ? "Insert/Edit" : "Embed"} Images</DialogHeader>
@@ -149,12 +118,12 @@ export default function ImageDialog(props: ImageDialogProps): ReactElement {
149118
e.preventDefault();
150119
}}
151120
>
152-
<a href="#">From datasource</a>
121+
<a href="#">Attachments</a>
153122
</li>
154123
</ul>
155124
</div>
156125
)}
157-
<div>
126+
<div ref={imageUploadElementRef}>
158127
<If condition={activeTab === "general"}>
159128
<FormControl label="Source">
160129
{defaultValue?.src ? (
@@ -174,15 +143,15 @@ export default function ImageDialog(props: ImageDialogProps): ReactElement {
174143
<span className="icons icon-Delete" onClick={onEmbedDeleted}></span>
175144
</span>
176145
</div>
177-
) : (
146+
) : enableDefaultUpload ? (
178147
<input
179148
name="files"
180149
className="form-control mx-textarea-input mx-textarea-noresize code-input"
181150
type="file"
182151
accept={IMG_MIME_TYPES.join(", ")}
183152
onChange={onFileChange}
184153
></input>
185-
)}
154+
) : undefined}
186155
</FormControl>
187156
<FormControl label="Alternative description">
188157
<input
@@ -217,7 +186,7 @@ export default function ImageDialog(props: ImageDialogProps): ReactElement {
217186
<DialogFooter onSubmit={() => onSubmit(formState)} onClose={onClose}></DialogFooter>
218187
</If>
219188
<If condition={activeTab === "embed"}>
220-
<EntityImageDialog {...props} onSelect={onEmbedSelected} />
189+
<div>{imageSourceContent}</div>
221190
</If>
222191
</div>
223192
</DialogBody>

packages/pluggableWidgets/rich-text-web/typings/RichTextProps.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* WARNING: All changes made to this file will be overwritten
44
* @author Mendix Widgets Framework Team
55
*/
6+
import { ComponentType, ReactNode } from "react";
67
import { ActionValue, EditableValue, ListValue } from "mendix";
78

89
export type PresetEnum = "basic" | "standard" | "full" | "custom";
@@ -71,6 +72,8 @@ export interface RichTextContainerProps {
7172
spellCheck: boolean;
7273
customFonts: CustomFontsType[];
7374
imageSource?: ListValue;
75+
imageSourceContent?: ReactNode;
76+
enableDefaultUpload: boolean;
7477
toolbarConfig: ToolbarConfigEnum;
7578
history: boolean;
7679
fontStyle: boolean;
@@ -114,6 +117,8 @@ export interface RichTextPreviewProps {
114117
spellCheck: boolean;
115118
customFonts: CustomFontsPreviewType[];
116119
imageSource: {} | { caption: string } | { type: string } | null;
120+
imageSourceContent: { widgetCount: number; renderer: ComponentType<{ children: ReactNode; caption?: string }> };
121+
enableDefaultUpload: boolean;
117122
toolbarConfig: ToolbarConfigEnum;
118123
history: boolean;
119124
fontStyle: boolean;

0 commit comments

Comments
 (0)