Skip to content

[WC-2991][WC-2919][WC-2960][WC-2986] Rich Text: add image to entity, maintain image ratio, support line breaks #1625

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Jul 1, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/pluggableWidgets/rich-text-web/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

## [Unreleased]

### Added

- We added support to choose image from entity using external widget.
- We added support to keep image ratio when resizing images.

### Fixed

- We fixed issue when empty paragraph do not shown as line break in html viewer by adding empty space content ` `.
- We fixed an issue where indented list throws error when reopen.

## [4.7.0] - 2025-06-02

### Added
Expand Down
2 changes: 1 addition & 1 deletion packages/pluggableWidgets/rich-text-web/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@mendix/rich-text-web",
"widgetName": "RichText",
"version": "4.7.0",
"version": "4.8.0",
"description": "Rich inline or toolbar text editing",
"copyright": "© Mendix Technology BV 2025. All rights reserved.",
"license": "Apache-2.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { Properties, hidePropertyIn, hidePropertiesIn } from "@mendix/pluggable-widgets-tools";
import { StructurePreviewProps } from "@mendix/widget-plugin-platform/preview/structure-preview-api";
import {
StructurePreviewProps,
dropzone,
container,
rowLayout
} from "@mendix/widget-plugin-platform/preview/structure-preview-api";
import { RichTextPreviewProps } from "typings/RichTextProps";
import RichTextPreviewSVGDark from "./assets/rich-text-preview-dark.svg";
import RichTextPreviewSVGLight from "./assets/rich-text-preview-light.svg";
Expand Down Expand Up @@ -58,16 +63,36 @@ export function getProperties(values: RichTextPreviewProps, defaultProperties: P
if (values.toolbarLocation === "hide") {
hidePropertyIn(defaultProperties, values, "preset");
}

if (values.imageSource === "none" || values.imageSource === null) {
hidePropertiesIn(defaultProperties, values, ["imageSourceContent", "enableDefaultUpload"]);
}
return defaultProperties;
}

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

return {
type: "Image",
document: props.stringAttribute ? doc.replace("[No attribute selected]", `[${props.stringAttribute}]`) : doc,
height: 150
};
const richTextPreview = container()(
rowLayout({ columnSize: "grow", borders: false })({
type: "Image",
document: props.stringAttribute
? doc.replace("[No attribute selected]", `[${props.stringAttribute}]`)
: doc,
height: 150
})
);

if (props.imageSource) {
richTextPreview.children?.push(
rowLayout({ columnSize: "grow", borders: true, borderWidth: 1, borderRadius: 2 })(
dropzone(
dropzone.placeholder("Place image selection widget here"),
dropzone.hideDataSourceHeaderIf(false)
)(props.imageSourceContent)
)
);
}
return richTextPreview;
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ export function preview(props: RichTextPreviewProps): ReactElement {
return (
<div className="widget-rich-text">
<img src={doc} alt="" />
{props.imageSource && (
<props.imageSourceContent.renderer caption="Place image selection widget here">
<div />
</props.imageSourceContent.renderer>
)}
</div>
);
}
12 changes: 12 additions & 0 deletions packages/pluggableWidgets/rich-text-web/src/RichText.xml
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,18 @@
</property>
</properties>
</property>
<property key="imageSource" type="datasource" isList="true" required="false">
<caption>Selectable images</caption>
<description />
</property>
<property key="imageSourceContent" type="widgets" required="false">
<caption>Content</caption>
<description>Content of a image uploader</description>
</property>
<property key="enableDefaultUpload" type="boolean" defaultValue="true">
<caption>Enable default upload</caption>
<description />
</property>
</propertyGroup>
</propertyGroup>
<propertyGroup caption="Custom toolbar">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,18 @@ export class QuillResizeToolbar {
}
}

// eslint-disable-next-line @typescript-eslint/no-extraneous-class
export class QuillResizeResize {
constructor() {
return this;
}
}

// eslint-disable-next-line @typescript-eslint/no-extraneous-class
export default class QuillResize {
static Modules = {
Toolbar: QuillResizeToolbar
Toolbar: QuillResizeToolbar,
Resize: QuillResizeResize
};
constructor() {
return this;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ describe("Rich Text", () => {
maxHeight: 0,
minHeight: 75,
OverflowY: "auto",
customFonts: []
customFonts: [],
enableDefaultUpload: true
};
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,21 +152,35 @@ export function useEmbedModal(ref: MutableRefObject<Quill | null>): ModalReturnT
dialogType: "image",
config: {
onSubmit: (value: imageConfigType) => {
const defaultImageConfig = {
alt: value.alt,
width: value.width,
height: value.keepAspectRatio ? undefined : value.height
};

if (value.src) {
const index = selection?.index ?? 0;
const length = 1;
const imageConfig = {
alt: value.alt,
width: value.width,
height: value.height
};
const imageConfig = defaultImageConfig;
// update existing image attribute
const imageUpdateDelta = new Delta().retain(index).retain(length, imageConfig);
ref.current?.updateContents(imageUpdateDelta, Emitter.sources.USER);
} else {
// upload new image
if (selection && value.files) {
uploadImage(ref, selection, value);
if (selection) {
if (value.files) {
uploadImage(ref, selection, value);
} else if (value.entityGuid) {
const imageConfig = {
...defaultImageConfig,
"data-src": value.entityGuid
};
const delta = new Delta()
.retain(selection.index)
.delete(selection.length)
.insert({ image: value.entityGuid }, imageConfig);
ref.current?.updateContents(delta, Emitter.sources.USER);
}
}
}
closeDialog();
Expand Down Expand Up @@ -213,7 +227,14 @@ function uploadImage(ref: MutableRefObject<Quill | null>, range: Range, options:
});
Promise.all(promises).then(images => {
const update = images.reduce((delta: Delta, image) => {
return delta.insert({ image }, { alt: options.alt, width: options.width, height: options.height });
return delta.insert(
{ image },
{
alt: options.alt,
width: options.width,
height: options.height
}
);
}, new Delta().retain(range.index).delete(range.length)) as Delta;
ref.current?.updateContents(update, Emitter.sources.USER);
ref.current?.setSelection(range.index + images.length, Emitter.sources.SILENT);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
useLayoutEffect,
useRef
} from "react";
import { CustomFontsType } from "../../typings/RichTextProps";
import { CustomFontsType, RichTextContainerProps } from "../../typings/RichTextProps";
import { EditorDispatchContext } from "../store/EditorProvider";
import { SET_FULLSCREEN_ACTION } from "../store/store";
import "../utils/customPluginRegisters";
Expand All @@ -30,8 +30,10 @@ import {
} from "./CustomToolbars/toolbarHandlers";
import { useEmbedModal } from "./CustomToolbars/useEmbedModal";
import Dialog from "./ModalDialog/Dialog";
import MxUploader from "../utils/modules/uploader";

export interface EditorProps {
export interface EditorProps
extends Pick<RichTextContainerProps, "imageSource" | "imageSourceContent" | "enableDefaultUpload"> {
customFonts: CustomFontsType[];
defaultValue?: string;
onTextChange?: (...args: [delta: Delta, oldContent: Delta, source: EmitterSource]) => void;
Expand Down Expand Up @@ -165,6 +167,7 @@ const Editor = forwardRef((props: EditorProps, ref: MutableRefObject<Quill | nul
if (arg[0].href) {
customLinkHandler(arg[0]);
} else if (arg[0].src) {
// open dialog editor for updating image or video
if (arg[0].type === "video") {
customVideoHandler(arg[0]);
} else {
Expand All @@ -175,6 +178,9 @@ const Editor = forwardRef((props: EditorProps, ref: MutableRefObject<Quill | nul
if (contextDispatch) {
contextDispatch(arg[0]);
}
} else if (arg[0].type === "image") {
// open dialog editor for updating image (triggered by module uploader)
customImageUploadHandler(arg[0]);
}
}
}
Expand All @@ -192,6 +198,13 @@ const Editor = forwardRef((props: EditorProps, ref: MutableRefObject<Quill | nul
[ref, toolbarId]
);

useEffect(() => {
// if image source is set from entity, handle upload differently
if (props.imageSource && props.imageSource.status === "available") {
(ref.current?.getModule("uploader") as MxUploader)?.setEntityUpload?.(true);
}
}, [props.imageSource, props.imageSource?.status, ref]);

return (
<Fragment>
<div ref={containerRef} style={style} className={className}></div>
Expand All @@ -200,6 +213,9 @@ const Editor = forwardRef((props: EditorProps, ref: MutableRefObject<Quill | nul
isOpen={showDialog}
onOpenChange={open => setShowDialog(open)}
parentNode={modalRef.current?.ownerDocument.body}
imageSource={props.imageSource}
imageSourceContent={props.imageSourceContent}
enableDefaultUpload={props.enableDefaultUpload}
{...dialogConfig}
></Dialog>
</Fragment>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,10 @@ function EditorWrapperInner(props: EditorWrapperProps): ReactElement {
readOnlyStyle,
toolbarOptions,
enableStatusBar,
tabIndex
tabIndex,
imageSource,
imageSourceContent,
enableDefaultUpload
} = props;

const globalState = useContext(EditorContext);
Expand Down Expand Up @@ -209,6 +212,9 @@ function EditorWrapperInner(props: EditorWrapperProps): ReactElement {
readOnly={stringAttribute.readOnly}
key={`${toolbarId}_${stringAttribute.readOnly}`}
customFonts={props.customFonts}
imageSource={imageSource}
imageSourceContent={imageSourceContent}
enableDefaultUpload={enableDefaultUpload}
/>
</div>
{enableStatusBar && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,112 @@

.code-input {
min-height: 50vh;

.cm-editor {
width: 100%;
}
}
}

&.image-dialog {
.image-dialog-size {
input {
min-width: 20px;
&[disabled] {
text-decoration: line-through;
}
}

&-input {
flex: 2;
align-items: center;
justify-content: center;
}

&-label {
flex: 1;
align-items: center;
justify-content: flex-end;
padding-right: var(--spacing-medium, 16px);
}

&-container {
align-items: center;
justify-content: center;
}
}

.image-dialog-upload {
max-height: var(--max-dialog-height, 70vh);
overflow-y: auto;
}
}

.nav-tabs {
li {
cursor: pointer;

a {
pointer-events: none;
}
}
}

.mx-image-dialog-list {
display: flex;
flex-wrap: wrap;
}

.mx-image-dialog-item {
padding-right: var(--spacing-small, 8px);
padding-bottom: var(--spacing-small, 8px);
cursor: pointer;

&:hover {
transform: scale3d(1.05, 1.05, 1);
transition: transform 0.2s ease-in-out;
}
}

.mx-image-dialog-thumbnail {
max-height: 100px;

&-small {
max-height: 50px;
}

&-container {
position: relative;

.icon-container {
position: absolute;
left: 0;
right: 0;
bottom: 0;
top: 0;
align-items: center;
justify-content: center;
display: none;

.icons {
border-radius: var(--border-radius-default, 4px);
background-color: var(--color-background, #fff);
cursor: pointer;
transition: transform 0.2s ease-in-out;

&:hover {
transform: scale3d(1.05, 1.05, 1);
}
}
}

&:hover {
.icon-container {
display: flex;
}
}
}
}
}

&-header {
Expand Down
Loading
Loading