diff --git a/packages/pluggableWidgets/rich-text-web/CHANGELOG.md b/packages/pluggableWidgets/rich-text-web/CHANGELOG.md
index 313d313968..9e041bb1d9 100644
--- a/packages/pluggableWidgets/rich-text-web/CHANGELOG.md
+++ b/packages/pluggableWidgets/rich-text-web/CHANGELOG.md
@@ -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
diff --git a/packages/pluggableWidgets/rich-text-web/package.json b/packages/pluggableWidgets/rich-text-web/package.json
index 3c0284c8f2..f32bf98fac 100644
--- a/packages/pluggableWidgets/rich-text-web/package.json
+++ b/packages/pluggableWidgets/rich-text-web/package.json
@@ -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",
diff --git a/packages/pluggableWidgets/rich-text-web/src/RichText.editorConfig.ts b/packages/pluggableWidgets/rich-text-web/src/RichText.editorConfig.ts
index b95ca47d9b..ad0529a1f9 100644
--- a/packages/pluggableWidgets/rich-text-web/src/RichText.editorConfig.ts
+++ b/packages/pluggableWidgets/rich-text-web/src/RichText.editorConfig.ts
@@ -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";
@@ -58,6 +63,10 @@ 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;
}
@@ -65,9 +74,25 @@ export function getPreview(props: RichTextPreviewProps, isDarkMode: boolean): St
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;
}
diff --git a/packages/pluggableWidgets/rich-text-web/src/RichText.editorPreview.tsx b/packages/pluggableWidgets/rich-text-web/src/RichText.editorPreview.tsx
index 4be531b4e7..2e8711a4f0 100644
--- a/packages/pluggableWidgets/rich-text-web/src/RichText.editorPreview.tsx
+++ b/packages/pluggableWidgets/rich-text-web/src/RichText.editorPreview.tsx
@@ -9,6 +9,11 @@ export function preview(props: RichTextPreviewProps): ReactElement {
return (

+ {props.imageSource && (
+
+
+
+ )}
);
}
diff --git a/packages/pluggableWidgets/rich-text-web/src/RichText.xml b/packages/pluggableWidgets/rich-text-web/src/RichText.xml
index b7b4ad71ba..94e21433e3 100644
--- a/packages/pluggableWidgets/rich-text-web/src/RichText.xml
+++ b/packages/pluggableWidgets/rich-text-web/src/RichText.xml
@@ -179,6 +179,18 @@
+
+ Selectable images
+
+
+
+ Content
+ Content of a image uploader
+
+
+ Enable default upload
+
+
diff --git a/packages/pluggableWidgets/rich-text-web/src/__mocks__/quill-resize-module.ts b/packages/pluggableWidgets/rich-text-web/src/__mocks__/quill-resize-module.ts
index fe31aadaaf..8842d5fc81 100644
--- a/packages/pluggableWidgets/rich-text-web/src/__mocks__/quill-resize-module.ts
+++ b/packages/pluggableWidgets/rich-text-web/src/__mocks__/quill-resize-module.ts
@@ -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;
diff --git a/packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.spec.tsx b/packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.spec.tsx
index 985627b886..a43372e9d4 100644
--- a/packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.spec.tsx
+++ b/packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.spec.tsx
@@ -44,7 +44,8 @@ describe("Rich Text", () => {
maxHeight: 0,
minHeight: 75,
OverflowY: "auto",
- customFonts: []
+ customFonts: [],
+ enableDefaultUpload: true
};
});
diff --git a/packages/pluggableWidgets/rich-text-web/src/components/CustomToolbars/useEmbedModal.ts b/packages/pluggableWidgets/rich-text-web/src/components/CustomToolbars/useEmbedModal.ts
index 3558a829f3..1dc0dec0c8 100644
--- a/packages/pluggableWidgets/rich-text-web/src/components/CustomToolbars/useEmbedModal.ts
+++ b/packages/pluggableWidgets/rich-text-web/src/components/CustomToolbars/useEmbedModal.ts
@@ -152,21 +152,35 @@ export function useEmbedModal(ref: MutableRefObject): 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();
@@ -213,7 +227,14 @@ function uploadImage(ref: MutableRefObject, 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);
diff --git a/packages/pluggableWidgets/rich-text-web/src/components/Editor.tsx b/packages/pluggableWidgets/rich-text-web/src/components/Editor.tsx
index 7a893cd380..fbf7a6a4a9 100644
--- a/packages/pluggableWidgets/rich-text-web/src/components/Editor.tsx
+++ b/packages/pluggableWidgets/rich-text-web/src/components/Editor.tsx
@@ -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";
@@ -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 {
customFonts: CustomFontsType[];
defaultValue?: string;
onTextChange?: (...args: [delta: Delta, oldContent: Delta, source: EmitterSource]) => void;
@@ -165,6 +167,7 @@ const Editor = forwardRef((props: EditorProps, ref: MutableRefObject {
+ // 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 (
@@ -200,6 +213,9 @@ const Editor = forwardRef((props: EditorProps, ref: MutableRefObject setShowDialog(open)}
parentNode={modalRef.current?.ownerDocument.body}
+ imageSource={props.imageSource}
+ imageSourceContent={props.imageSourceContent}
+ enableDefaultUpload={props.enableDefaultUpload}
{...dialogConfig}
>
diff --git a/packages/pluggableWidgets/rich-text-web/src/components/EditorWrapper.tsx b/packages/pluggableWidgets/rich-text-web/src/components/EditorWrapper.tsx
index c8977ea3b9..02a1957fbb 100644
--- a/packages/pluggableWidgets/rich-text-web/src/components/EditorWrapper.tsx
+++ b/packages/pluggableWidgets/rich-text-web/src/components/EditorWrapper.tsx
@@ -49,7 +49,10 @@ function EditorWrapperInner(props: EditorWrapperProps): ReactElement {
readOnlyStyle,
toolbarOptions,
enableStatusBar,
- tabIndex
+ tabIndex,
+ imageSource,
+ imageSourceContent,
+ enableDefaultUpload
} = props;
const globalState = useContext(EditorContext);
@@ -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}
/>
{enableStatusBar && (
diff --git a/packages/pluggableWidgets/rich-text-web/src/components/ModalDialog/Dialog.scss b/packages/pluggableWidgets/rich-text-web/src/components/ModalDialog/Dialog.scss
index 8a7aa9fb41..0638acf03e 100644
--- a/packages/pluggableWidgets/rich-text-web/src/components/ModalDialog/Dialog.scss
+++ b/packages/pluggableWidgets/rich-text-web/src/components/ModalDialog/Dialog.scss
@@ -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 {
diff --git a/packages/pluggableWidgets/rich-text-web/src/components/ModalDialog/Dialog.tsx b/packages/pluggableWidgets/rich-text-web/src/components/ModalDialog/Dialog.tsx
index 8bb9d81438..1256ec9fb4 100644
--- a/packages/pluggableWidgets/rich-text-web/src/components/ModalDialog/Dialog.tsx
+++ b/packages/pluggableWidgets/rich-text-web/src/components/ModalDialog/Dialog.tsx
@@ -15,6 +15,7 @@ import VideoDialog, { VideoDialogProps } from "./VideoDialog";
import ViewCodeDialog, { ViewCodeDialogProps } from "./ViewCodeDialog";
import ImageDialog, { ImageDialogProps } from "./ImageDialog";
import "./Dialog.scss";
+import { RichTextContainerProps } from "../../../typings/RichTextProps";
interface BaseDialogProps {
isOpen: boolean;
@@ -48,13 +49,15 @@ export type ChildDialogProps =
| ViewCodeDialogBaseProps
| ImageDialogBaseProps;
-export type DialogProps = BaseDialogProps & ChildDialogProps;
+export type DialogProps = BaseDialogProps &
+ ChildDialogProps &
+ Pick;
/**
* Dialog components that will be shown on toolbar's button
*/
export default function Dialog(props: DialogProps): ReactElement {
- const { isOpen, onOpenChange, dialogType, config } = props;
+ const { isOpen, onOpenChange, dialogType, config, imageSource, imageSourceContent, enableDefaultUpload } = props;
const { refs, context } = useFloating({
open: isOpen,
onOpenChange
@@ -69,7 +72,7 @@ export default function Dialog(props: DialogProps): ReactElement {
const { getFloatingProps } = useInteractions([click, dismiss, role]);
return (
-
+
{isOpen && (
-
+
diff --git a/packages/pluggableWidgets/rich-text-web/src/components/ModalDialog/DialogContent.tsx b/packages/pluggableWidgets/rich-text-web/src/components/ModalDialog/DialogContent.tsx
index e4e76d2683..1bdb66946c 100644
--- a/packages/pluggableWidgets/rich-text-web/src/components/ModalDialog/DialogContent.tsx
+++ b/packages/pluggableWidgets/rich-text-web/src/components/ModalDialog/DialogContent.tsx
@@ -1,3 +1,4 @@
+import { If } from "@mendix/widget-plugin-component-kit/If";
import classNames from "classnames";
import { createElement, Fragment, PropsWithChildren, ReactElement } from "react";
@@ -49,10 +50,12 @@ export function FormControl(props: FormControlProps): ReactElement {
const { children, className, label } = props;
return (
-
- {label &&
}
-
{children}
-
+
+
+ {label &&
}
+
{children}
+
+
);
}
diff --git a/packages/pluggableWidgets/rich-text-web/src/components/ModalDialog/ImageDialog.tsx b/packages/pluggableWidgets/rich-text-web/src/components/ModalDialog/ImageDialog.tsx
index 1f65383f51..d6dba5215d 100644
--- a/packages/pluggableWidgets/rich-text-web/src/components/ModalDialog/ImageDialog.tsx
+++ b/packages/pluggableWidgets/rich-text-web/src/components/ModalDialog/ImageDialog.tsx
@@ -1,28 +1,81 @@
-import { ChangeEvent, createElement, ReactElement, useEffect, useRef, useState } from "react";
+import { If } from "@mendix/widget-plugin-component-kit/If";
+import classNames from "classnames";
+import { ChangeEvent, createElement, ReactElement, SyntheticEvent, useEffect, useRef, useState } from "react";
+import { RichTextContainerProps } from "../../../typings/RichTextProps";
import { type imageConfigType } from "../../utils/formats";
-import { DialogBody, DialogContent, DialogFooter, DialogHeader, FormControl } from "./DialogContent";
import { IMG_MIME_TYPES } from "../CustomToolbars/constants";
+import { DialogBody, DialogContent, DialogFooter, DialogHeader, FormControl } from "./DialogContent";
+
+type Image = {
+ id: string;
+ url: string;
+ thumbnailUrl?: string;
+};
-export interface ImageDialogProps {
+interface CustomEvent extends Event {
+ /**
+ * Returns any custom data event was created with. Typically used for synthetic events.
+ */
+ readonly detail: T;
+ initCustomEvent(typeArg: string, canBubbleArg: boolean, cancelableArg: boolean, detailArg: T): void;
+}
+
+export interface ImageDialogProps extends Pick {
onSubmit(value: imageConfigType): void;
onClose(): void;
defaultValue?: imageConfigType;
+ enableDefaultUpload?: boolean;
}
export default function ImageDialog(props: ImageDialogProps): ReactElement {
- const { onSubmit, onClose, defaultValue } = props;
- const inputReference = useRef(null);
+ const { onClose, defaultValue, onSubmit, imageSource, imageSourceContent, enableDefaultUpload } = props;
+ const [activeTab, setActiveTab] = useState("general");
+ const [selectedImageEntity, setSelectedImageEntity] = useState();
+ const imageUploadElementRef = useRef(null);
+ // disable embed tab if it is about modifying current video
+ const disableEmbed =
+ (defaultValue?.src && defaultValue.src.length > 0) ||
+ imageSource === undefined ||
+ imageSource?.status !== "available";
+ const inputReference = useRef(null);
+ const isInputProcessed = useRef(false);
useEffect(() => {
setTimeout(() => inputReference?.current?.focus(), 50);
- }, []);
+ if (
+ !disableEmbed &&
+ imageSource &&
+ imageSource.status === "available" &&
+ defaultValue?.files &&
+ !isInputProcessed.current
+ ) {
+ // if there is a file given, and imageSource is available
+ // assume that we want to do image upload to entity
+ // and switch to embed tab
+ setActiveTab("embed");
+ }
+ }, [defaultValue?.files, disableEmbed, imageSource]);
+
+ useEffect(() => {
+ if (activeTab === "embed" && defaultValue?.files && !isInputProcessed.current) {
+ // upload image directly to entity using external file uploader widget (if available)
+ const inputFiles = imageUploadElementRef.current?.querySelector("input[type='file']") as HTMLInputElement;
+ if (inputFiles) {
+ inputFiles.files = defaultValue.files as FileList;
+ inputFiles.dispatchEvent(new Event("change", { bubbles: true }));
+ }
+ isInputProcessed.current = true;
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [activeTab]);
const [formState, setFormState] = useState({
files: null,
alt: defaultValue?.alt ?? "",
width: defaultValue?.width ?? 100,
height: defaultValue?.height ?? 100,
- src: defaultValue?.src ?? undefined
+ src: defaultValue?.src ?? undefined,
+ keepAspectRatio: true
});
const onFileChange = (e: ChangeEvent): void => {
@@ -35,54 +88,161 @@ export default function ImageDialog(props: ImageDialogProps): ReactElement {
setFormState({ ...formState, [e.target.name]: e.target.value });
};
+ const onInputCheckboxChange = (e: ChangeEvent): void => {
+ e.stopPropagation();
+ setFormState({ ...formState, keepAspectRatio: !formState.keepAspectRatio });
+ };
+
+ const onEmbedSelected = (image: Image): void => {
+ setFormState({ ...formState, entityGuid: image.id, src: undefined, files: null });
+ setSelectedImageEntity(image);
+ setActiveTab("general");
+ };
+
+ const handleImageSelected = (event: CustomEvent): void => {
+ const image = event.detail;
+ onEmbedSelected(image);
+ };
+
+ const onEmbedDeleted = (): void => {
+ setFormState({ ...formState, entityGuid: undefined, src: undefined });
+ setSelectedImageEntity(undefined);
+ };
+
+ const onImageLoaded = (e: SyntheticEvent): void => {
+ setFormState({ ...formState, width: e.currentTarget.naturalWidth, height: e.currentTarget.naturalHeight });
+ };
+
+ useEffect(() => {
+ // event listener for image selection triggered from custom widgets JS Action
+ const imgRef = imageUploadElementRef.current;
+
+ if (imgRef !== null) {
+ imgRef.addEventListener("imageSelected", handleImageSelected);
+ }
+ return () => {
+ imgRef?.removeEventListener("imageSelected", handleImageSelected);
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [imageUploadElementRef.current]);
+
return (
- Insert/Edit Image
+ {activeTab === "general" ? "Insert/Edit" : "Embed"} Images
-
- {defaultValue?.src ? (
-
- ) : (
-
+
+ {!disableEmbed && (
+
+
+ - setActiveTab("general")}
+ >
+ General
+
+ - {
+ setActiveTab("embed");
+ e.stopPropagation();
+ e.preventDefault();
+ }}
+ >
+ Attachments
+
+
+
)}
-
-
-
-
-
-
- px
-
-
-
- px
-
-
onSubmit(formState)} onClose={onClose}>
+
+
);
diff --git a/packages/pluggableWidgets/rich-text-web/src/package.xml b/packages/pluggableWidgets/rich-text-web/src/package.xml
index b3a751ba63..ce94754a12 100644
--- a/packages/pluggableWidgets/rich-text-web/src/package.xml
+++ b/packages/pluggableWidgets/rich-text-web/src/package.xml
@@ -1,6 +1,6 @@
-
+
diff --git a/packages/pluggableWidgets/rich-text-web/src/ui/RichText.scss b/packages/pluggableWidgets/rich-text-web/src/ui/RichText.scss
index ada39769cd..ec3c37b5c1 100644
--- a/packages/pluggableWidgets/rich-text-web/src/ui/RichText.scss
+++ b/packages/pluggableWidgets/rich-text-web/src/ui/RichText.scss
@@ -146,10 +146,6 @@ $rte-brand-primary: #264ae5;
}
}
- [aria-hidden="true"] {
- display: none;
- }
-
.flexcontainer.flex-column {
overflow: visible;
}
diff --git a/packages/pluggableWidgets/rich-text-web/src/utils/MxQuill.ts b/packages/pluggableWidgets/rich-text-web/src/utils/MxQuill.ts
index fb4b617e33..80dd082a3d 100644
--- a/packages/pluggableWidgets/rich-text-web/src/utils/MxQuill.ts
+++ b/packages/pluggableWidgets/rich-text-web/src/utils/MxQuill.ts
@@ -42,6 +42,14 @@ import TextBlot, { escapeText } from "quill/blots/text";
import { Delta, Op } from "quill/core";
import Editor from "quill/core/editor";
+interface ListItem {
+ child: Blot;
+ offset: number;
+ length: number;
+ indent: number;
+ type: string;
+}
+
/**
* Rich Text's extended Quill Editor
* allowing us to override certain editor's function, such as: getHTML
@@ -110,25 +118,23 @@ function getExpectedType(type: string | undefined, indent: number): string {
/**
* Copy with modification from https://github.com/slab/quill/blob/main/packages/quill/src/core/editor.ts
*/
-function convertListHTML(items: any[], lastIndent: number, types: string[]): string {
+function convertListHTML(items: ListItem[], lastIndent: number, types: string[]): string {
if (items.length === 0) {
const [endTag] = getListType(types.pop());
if (lastIndent <= 0) {
- // modified by web-content: adding new line \n
- return `${endTag}>\n`;
+ return `${endTag}>`;
}
- // modified by web-content: adding new line \n
- return `${endTag}>\n${convertListHTML([], lastIndent - 1, types)}`;
+ return `${endTag}>${convertListHTML([], lastIndent - 1, types)}`;
}
const [{ child, offset, length, indent, type }, ...rest] = items;
const [tag, attribute] = getListType(type);
- // modified by web-content: get proper list-style-type
- const expectedType = getExpectedType(type, indent);
+
if (indent > lastIndent) {
+ // modified by web-content: get proper list-style-type
+ const expectedType = getExpectedType(type, indent);
types.push(type);
if (indent === lastIndent + 1) {
- // modified by web-content: adding list-style-type to allow retaining list style when converted to html and new line \n
- return `<${tag} style="list-style-type: ${expectedType}">\n${convertHTML(
+ return `<${tag} style="list-style-type: ${expectedType}">${convertHTML(
child,
offset,
length
@@ -138,12 +144,10 @@ function convertListHTML(items: any[], lastIndent: number, types: string[]): str
}
const previousType = types[types.length - 1];
if (indent === lastIndent && type === previousType) {
- // modified by web-content: adding new line \n
- return `\n${convertHTML(child, offset, length)}${convertListHTML(rest, indent, types)}`;
+ return `${convertHTML(child, offset, length)}${convertListHTML(rest, indent, types)}`;
}
const [endTag] = getListType(types.pop());
- // modified by web-content: adding new line \n
- return `${endTag}>\n${convertListHTML(items, lastIndent - 1, types)}`;
+ return `${endTag}>${convertListHTML(items, lastIndent - 1, types)}`;
}
/**
@@ -156,7 +160,8 @@ function convertHTML(blot: Blot, index: number, length: number, isRoot = false):
return blot.html(index, length);
}
if (blot instanceof TextBlot) {
- return escapeText(blot.value().slice(index, index + length));
+ const escapedText = escapeText(blot.value().slice(index, index + length));
+ return escapedText.replaceAll(" ", " ");
}
if (blot instanceof ParentBlot) {
// TODO fix API
diff --git a/packages/pluggableWidgets/rich-text-web/src/utils/customPluginRegisters.ts b/packages/pluggableWidgets/rich-text-web/src/utils/customPluginRegisters.ts
index dbdb6e78f0..a027259642 100644
--- a/packages/pluggableWidgets/rich-text-web/src/utils/customPluginRegisters.ts
+++ b/packages/pluggableWidgets/rich-text-web/src/utils/customPluginRegisters.ts
@@ -5,6 +5,7 @@ import "./formats/fontsize";
import CustomListItem from "./formats/customList";
import CustomLink from "./formats/link";
import CustomVideo from "./formats/video";
+import CustomImage from "./formats/image";
import Button from "./formats/button";
import { Attributor } from "parchment";
const direction = Quill.import("attributors/style/direction") as Attributor;
@@ -13,6 +14,8 @@ import { IndentLeftStyle, IndentRightStyle } from "./formats/indent";
import Formula from "./formats/formula";
import QuillResize from "quill-resize-module";
import QuillTableBetter from "./formats/quill-table-better/quill-table-better";
+import MxUploader from "./modules/uploader";
+import MxBlock from "./formats/block";
class Empty {
doSomething(): string {
@@ -27,12 +30,15 @@ Quill.register({ "themes/snow": MendixTheme }, true);
Quill.register(CustomListItem, true);
Quill.register(CustomLink, true);
Quill.register(CustomVideo, true);
+Quill.register(CustomImage, true);
Quill.register(direction, true);
Quill.register(alignment, true);
Quill.register(IndentLeftStyle, true);
Quill.register(IndentRightStyle, true);
Quill.register(Formula, true);
Quill.register(Button, true);
+Quill.register(MxBlock, true);
+Quill.register({ "modules/uploader": MxUploader }, true);
Quill.register("modules/resize", QuillResize, true);
// add empty handler for view code, this format is handled by toolbar's custom config via ViewCodeDialog
Quill.register({ "ui/view-code": Empty });
diff --git a/packages/pluggableWidgets/rich-text-web/src/utils/formats.d.ts b/packages/pluggableWidgets/rich-text-web/src/utils/formats.d.ts
index 77f2f406d7..6058445ab1 100644
--- a/packages/pluggableWidgets/rich-text-web/src/utils/formats.d.ts
+++ b/packages/pluggableWidgets/rich-text-web/src/utils/formats.d.ts
@@ -25,4 +25,6 @@ export type imageConfigType = {
width?: number;
height?: number;
src?: string;
+ entityGuid?: string;
+ keepAspectRatio?: boolean;
};
diff --git a/packages/pluggableWidgets/rich-text-web/src/utils/formats/block.ts b/packages/pluggableWidgets/rich-text-web/src/utils/formats/block.ts
new file mode 100644
index 0000000000..2a7460d457
--- /dev/null
+++ b/packages/pluggableWidgets/rich-text-web/src/utils/formats/block.ts
@@ -0,0 +1,17 @@
+import Block from "quill/blots/block";
+
+class MxBlock extends Block {
+ html(): string {
+ // quill return empty paragraph when there is no content (just empty line)
+ // to preserve the line breaks, we add empty space
+ if (this.domNode.childElementCount === 1 && this.domNode.children[0] instanceof HTMLBRElement) {
+ return this.domNode.outerHTML.replace(/
/g, " ");
+ } else if (this.domNode.childElementCount === 0 && this.domNode.textContent?.trim() === "") {
+ this.domNode.innerHTML = " ";
+ return this.domNode.outerHTML;
+ } else {
+ return this.domNode.outerHTML;
+ }
+ }
+}
+export default MxBlock;
diff --git a/packages/pluggableWidgets/rich-text-web/src/utils/formats/image.ts b/packages/pluggableWidgets/rich-text-web/src/utils/formats/image.ts
new file mode 100644
index 0000000000..f9d3ae910e
--- /dev/null
+++ b/packages/pluggableWidgets/rich-text-web/src/utils/formats/image.ts
@@ -0,0 +1,38 @@
+import Image from "quill/formats/image";
+import { fetchDocumentUrl } from "../mx-data";
+
+const ATTRIBUTES = ["alt", "height", "width", "data-src"];
+
+class CustomImage extends Image {
+ format(name: string, value: string): void {
+ if (ATTRIBUTES.indexOf(name) > -1) {
+ if (name === "src" && this.domNode.hasAttribute("data-src")) {
+ return; // Do not set src directly, use data-src instead
+ }
+ if (value) {
+ this.domNode.setAttribute(name, value);
+ } else {
+ this.domNode.removeAttribute(name);
+ }
+ } else {
+ super.format(name, value);
+ }
+
+ if (name === "data-src" && !this.domNode.dataset.entity) {
+ this.domNode.setAttribute("src", fetchDocumentUrl(value, Date.now()));
+ // Mark the image as an entity to prevent further src changes
+ this.domNode.setAttribute("data-entity", "true");
+ }
+ }
+
+ static formats(domNode: Element): any {
+ return ATTRIBUTES.reduce((formats: Record, attribute) => {
+ if (domNode.hasAttribute(attribute)) {
+ formats[attribute] = domNode.getAttribute(attribute);
+ }
+ return formats;
+ }, {});
+ }
+}
+
+export default CustomImage;
diff --git a/packages/pluggableWidgets/rich-text-web/src/utils/formats/quill-table-better/assets/css/quill-table-better.scss b/packages/pluggableWidgets/rich-text-web/src/utils/formats/quill-table-better/assets/css/quill-table-better.scss
index 35bd99c009..7889ae6db5 100644
--- a/packages/pluggableWidgets/rich-text-web/src/utils/formats/quill-table-better/assets/css/quill-table-better.scss
+++ b/packages/pluggableWidgets/rich-text-web/src/utils/formats/quill-table-better/assets/css/quill-table-better.scss
@@ -31,14 +31,17 @@ $focused-border: 1px solid $focused-border-color;
left: 50%;
bottom: -10px;
transform: translate(-50%, 100%);
+
&::before {
@extend .ql-table-triangle-common;
border-bottom-color: $color !important;
top: -20px;
}
+
&:hover {
display: block;
}
+
&-hidden {
display: none !important;
}
@@ -50,6 +53,7 @@ $focused-border: 1px solid $focused-border-color;
#{$direction1}: -20px;
border-#{$direction2}-color: $color1 !important;
}
+
&:not(.ql-table-triangle-none)::after {
@extend .ql-table-triangle-common;
#{$direction1}: -19px;
@@ -299,6 +303,7 @@ $focused-border: 1px solid $focused-border-color;
line-height: 30px;
list-style: none;
padding-left: 10px;
+
&:hover {
background-color: $hover-background;
}
@@ -521,8 +526,11 @@ $focused-border: 1px solid $focused-border-color;
&-error {
@include qlTableTooltip($tooltip-color-error);
- white-space: pre-wrap;
- z-index: 9;
+
+ & {
+ white-space: pre-wrap;
+ z-index: 9;
+ }
}
}
@@ -559,6 +567,7 @@ $focused-border: 1px solid $focused-border-color;
z-index: 10;
border: 1px solid #979797;
cursor: nwse-resize;
+
&-move {
cursor: crosshair;
border: none;
@@ -574,6 +583,7 @@ $focused-border: 1px solid $focused-border-color;
position: absolute;
z-index: 10;
@extend .ql-table-center;
+
.ql-operate-line {
background-color: $line-color;
}
@@ -653,12 +663,15 @@ ol.table-list-container {
20% {
transform: translateX(-2px);
}
+
40% {
transform: translateX(2px);
}
+
60% {
transform: translateX(-1px);
}
+
80% {
transform: translateX(1px);
}
diff --git a/packages/pluggableWidgets/rich-text-web/src/utils/formats/resizeModuleConfig.ts b/packages/pluggableWidgets/rich-text-web/src/utils/formats/resizeModuleConfig.ts
index 3a2ec827ca..72f64d6dfb 100644
--- a/packages/pluggableWidgets/rich-text-web/src/utils/formats/resizeModuleConfig.ts
+++ b/packages/pluggableWidgets/rich-text-web/src/utils/formats/resizeModuleConfig.ts
@@ -1,46 +1,11 @@
import Quill from "quill";
import QuillResize from "quill-resize-module";
import { ACTION_DISPATCHER } from "../helpers";
-
-type ToolbarTool = {
- text: string;
- className: string;
- verify: (activeEle: HTMLElement) => boolean;
- handler: (
- this: typeof QuillResize.Modules.Base,
- _evt: MouseEvent,
- _button: HTMLElement,
- activeEle: HTMLIFrameElement
- ) => void;
-};
-
-// eslint-disable-next-line no-unsafe-optional-chaining
-class MxResizeToolbar extends QuillResize.Modules?.Toolbar {
- _addToolbarButtons(): void {
- const buttons: HTMLButtonElement[] = [];
- this.options.tools.forEach((tool: ToolbarTool) => {
- if (tool.verify && tool.verify.call(this, this.activeEle) === false) {
- return;
- }
-
- const button = document.createElement("button");
- button.className = tool.className;
- buttons.push(button);
- button.setAttribute("aria-label", tool.text);
- button.setAttribute("type", "button");
-
- button.addEventListener("click", evt => {
- tool.handler.call(this, evt, button, this.activeEle);
- // image may change position; redraw drag handles
- this.requestUpdate();
- });
- this.toolbar.appendChild(button);
- });
- }
-}
+import MxResizeToolbar from "../modules/resizeToolbar";
+import MxResize from "../modules/resize";
export const RESIZE_MODULE_CONFIG = {
- modules: ["DisplaySize", MxResizeToolbar, "Resize", "Keyboard"],
+ modules: ["DisplaySize", MxResizeToolbar, MxResize, "Keyboard"],
tools: [
{
text: "Edit Image",
diff --git a/packages/pluggableWidgets/rich-text-web/src/utils/helpers.ts b/packages/pluggableWidgets/rich-text-web/src/utils/helpers.ts
index bcb3a62361..e274e91141 100644
--- a/packages/pluggableWidgets/rich-text-web/src/utils/helpers.ts
+++ b/packages/pluggableWidgets/rich-text-web/src/utils/helpers.ts
@@ -38,7 +38,7 @@ export function constructWrapperStyle(props: RichTextContainerProps): CSSPropert
export function updateLegacyQuillFormats(quill: Quill): boolean {
const results = transformLegacyQuillFormats(quill.getContents());
if (results.isDirty) {
- quill.setContents(results.data, Quill.sources.USER);
+ quill.setContents(new Delta(results.data), Quill.sources.API);
}
return results.isDirty;
}
diff --git a/packages/pluggableWidgets/rich-text-web/src/utils/modules/resize.ts b/packages/pluggableWidgets/rich-text-web/src/utils/modules/resize.ts
new file mode 100644
index 0000000000..8c88b4ee8d
--- /dev/null
+++ b/packages/pluggableWidgets/rich-text-web/src/utils/modules/resize.ts
@@ -0,0 +1,103 @@
+import QuillResize from "quill-resize-module";
+
+type ImageSize = {
+ width?: number;
+ height?: number;
+};
+
+type ImageSizeWithUnit = {
+ width?: string;
+ height?: string;
+};
+
+type ImageSizes = ImageSize | ImageSizeWithUnit;
+
+type LimitConfig = {
+ ratio?: number;
+ minWidth?: number;
+ maxWidth?: number;
+ minHeight?: number;
+ maxHeight?: number;
+ unit?: string;
+};
+
+type CalculateSizeEvent = { clientX: number; clientY: number };
+
+export default class MxResize extends QuillResize.Modules.Resize {
+ // modified from https://github.com/mudoo/quill-resize-module/blob/master/src/modules/Resize.js
+ calcSize(evt: CalculateSizeEvent, limit: LimitConfig = {}): ImageSizes {
+ // update size
+ const deltaX = evt.clientX - this.dragStartX;
+ const deltaY = evt.clientY - this.dragStartY;
+
+ const size: ImageSize = {};
+ let direction = 1;
+
+ (this.blotOptions.attribute || ["width"]).forEach((key: "width" | "height") => {
+ size[key] = this.preDragSize[key];
+ });
+
+ // modification to check if height attribute is exist from current image
+ // this is to maintain ratio by resizing width only
+ const allowHeight =
+ this.activeEle.getAttribute("height") !== undefined && this.activeEle.getAttribute("height") !== null;
+ if (!allowHeight) {
+ delete size.height;
+ }
+
+ // left-side
+ if (this.dragBox === this.boxes[0] || this.dragBox === this.boxes[3]) {
+ direction = -1;
+ }
+
+ if (size.width) {
+ size.width = Math.round(this.preDragSize.width + deltaX * direction);
+ }
+ if (size.height) {
+ size.height = Math.round(this.preDragSize.height + deltaY * direction);
+ }
+
+ let { width, height } = size;
+
+ // keep ratio
+ if (limit.ratio) {
+ let limitHeight;
+ if (limit.minWidth) width = Math.max(limit.minWidth, width!);
+ if (limit.maxWidth) width = Math.min(limit.maxWidth, width!);
+
+ height = width! * limit.ratio;
+
+ if (limit.minHeight && height < limit.minHeight) {
+ limitHeight = true;
+ height = limit.minHeight;
+ }
+ if (limit.maxHeight && height > limit.maxHeight) {
+ limitHeight = true;
+ height = limit.maxHeight;
+ }
+
+ if (limitHeight) {
+ width = height / limit.ratio;
+ }
+ } else {
+ if (size.width) {
+ if (limit.minWidth) width = Math.max(limit.minWidth, width!);
+ if (limit.maxWidth) width = Math.min(limit.maxWidth, width!);
+ }
+ if (size.height) {
+ if (limit.minHeight) height = Math.max(limit.minHeight, height!);
+ if (limit.maxHeight) height = Math.min(limit.maxHeight, height!);
+ }
+ }
+ const res: ImageSizes = {};
+
+ if (limit.unit) {
+ if (width) res.width = width + "px";
+ if (height) res.height = height + "px";
+ } else {
+ if (width) res.width = width;
+ if (height) res.height = height;
+ }
+ return res;
+ }
+}
diff --git a/packages/pluggableWidgets/rich-text-web/src/utils/modules/resizeToolbar.ts b/packages/pluggableWidgets/rich-text-web/src/utils/modules/resizeToolbar.ts
new file mode 100644
index 0000000000..54311977a8
--- /dev/null
+++ b/packages/pluggableWidgets/rich-text-web/src/utils/modules/resizeToolbar.ts
@@ -0,0 +1,38 @@
+import QuillResize from "quill-resize-module";
+
+type ToolbarTool = {
+ text: string;
+ className: string;
+ verify: (activeEle: HTMLElement) => boolean;
+ handler: (
+ this: typeof QuillResize.Modules.Base,
+ _evt: MouseEvent,
+ _button: HTMLElement,
+ activeEle: HTMLIFrameElement
+ ) => void;
+};
+
+// eslint-disable-next-line no-unsafe-optional-chaining
+export default class MxResizeToolbar extends QuillResize.Modules?.Toolbar {
+ _addToolbarButtons(): void {
+ const buttons: HTMLButtonElement[] = [];
+ this.options.tools.forEach((tool: ToolbarTool) => {
+ if (tool.verify && tool.verify.call(this, this.activeEle) === false) {
+ return;
+ }
+
+ const button = document.createElement("button");
+ button.className = tool.className;
+ buttons.push(button);
+ button.setAttribute("aria-label", tool.text);
+ button.setAttribute("type", "button");
+
+ button.addEventListener("click", evt => {
+ tool.handler.call(this, evt, button, this.activeEle);
+ // image may change position; redraw drag handles
+ this.requestUpdate();
+ });
+ this.toolbar.appendChild(button);
+ });
+ }
+}
diff --git a/packages/pluggableWidgets/rich-text-web/src/utils/modules/uploader.ts b/packages/pluggableWidgets/rich-text-web/src/utils/modules/uploader.ts
new file mode 100644
index 0000000000..2ff058eb8c
--- /dev/null
+++ b/packages/pluggableWidgets/rich-text-web/src/utils/modules/uploader.ts
@@ -0,0 +1,35 @@
+import { Range } from "quill/core/selection";
+import Uploader from "quill/modules/uploader";
+import { ACTION_DISPATCHER } from "../helpers";
+
+class MxUploader extends Uploader {
+ protected useEntityUpload: boolean = false;
+
+ setEntityUpload(useEntityUpload: boolean): void {
+ this.useEntityUpload = useEntityUpload;
+ }
+
+ upload(range: Range, files: FileList | File[]): void {
+ if (!this.quill.scroll.query("image")) {
+ return;
+ }
+ if (this.useEntityUpload) {
+ // If entity upload is enabled, the file will be handled by external widget's upload handler.
+ const dataTransfer = new DataTransfer();
+ Array.from(files).forEach(file => {
+ if (file && this.options.mimetypes?.includes(file.type)) {
+ dataTransfer.items.add(file);
+ }
+ });
+ const imageInfo = {
+ type: "image",
+ files: dataTransfer.files
+ };
+ this.quill.emitter.emit(ACTION_DISPATCHER, imageInfo);
+ } else {
+ super.upload.call(this, range, files);
+ }
+ }
+}
+
+export default MxUploader;
diff --git a/packages/pluggableWidgets/rich-text-web/src/utils/mx-data.ts b/packages/pluggableWidgets/rich-text-web/src/utils/mx-data.ts
new file mode 100644
index 0000000000..9735936616
--- /dev/null
+++ b/packages/pluggableWidgets/rich-text-web/src/utils/mx-data.ts
@@ -0,0 +1,58 @@
+import { ObjectItem } from "mendix";
+import { Big } from "big.js";
+
+export type MxObject = {
+ getGuid(): string;
+ getEntity(): string;
+ get(name: string): string | Big | boolean;
+ get2(name: string): string | Big | boolean;
+};
+
+export function saveFile(item: ObjectItem, fileToUpload: Blob): Promise {
+ return new Promise((resolve, reject) => {
+ (window as any).mx.data.saveDocument(item.id, null, {}, fileToUpload, resolve, reject);
+ });
+}
+
+export function removeObject(item: ObjectItem): Promise {
+ return new Promise((resolve, reject) => {
+ (window as any).mx.data.remove({
+ guid: item.id,
+ callback: resolve,
+ error: reject
+ });
+ });
+}
+
+export function fileHasContents(item: ObjectItem): boolean {
+ const obj = (item as any)[Object.getOwnPropertySymbols(item)[0]];
+ return !!obj.get2("HasContents");
+}
+
+export function getMxObject(item: ObjectItem): MxObject {
+ return (item as any)[Object.getOwnPropertySymbols(item)[0]];
+}
+
+export function fetchMxObject(objectItem: ObjectItem): Promise {
+ return new Promise((resolve, reject) => {
+ (window as any).mx.data.get({
+ guid: objectItem.id,
+ callback: resolve,
+ error: reject
+ });
+ });
+}
+
+export function isImageObject(mxObject: MxObject): boolean {
+ return (window as any).mx.meta.getEntity(mxObject.getEntity()).isA("System.Image");
+}
+
+export function fetchDocumentUrl(guid: string, changeDate?: number): string {
+ return (window as any).mx.data.getDocumentUrl(guid, changeDate ?? Date.now(), false);
+}
+
+export function fetchImageThumbnail(docUrl: string): Promise {
+ return new Promise((resolve, reject) => {
+ (window as any).mx.data.getImageUrl(docUrl, resolve, reject);
+ });
+}
diff --git a/packages/pluggableWidgets/rich-text-web/typings/RichTextProps.d.ts b/packages/pluggableWidgets/rich-text-web/typings/RichTextProps.d.ts
index 7aa3da5485..f3db8e6516 100644
--- a/packages/pluggableWidgets/rich-text-web/typings/RichTextProps.d.ts
+++ b/packages/pluggableWidgets/rich-text-web/typings/RichTextProps.d.ts
@@ -3,7 +3,8 @@
* WARNING: All changes made to this file will be overwritten
* @author Mendix Widgets Framework Team
*/
-import { ActionValue, EditableValue } from "mendix";
+import { ComponentType, ReactNode } from "react";
+import { ActionValue, EditableValue, ListValue } from "mendix";
export type PresetEnum = "basic" | "standard" | "full" | "custom";
@@ -70,6 +71,9 @@ export interface RichTextContainerProps {
onChangeType: OnChangeTypeEnum;
spellCheck: boolean;
customFonts: CustomFontsType[];
+ imageSource?: ListValue;
+ imageSourceContent?: ReactNode;
+ enableDefaultUpload: boolean;
toolbarConfig: ToolbarConfigEnum;
history: boolean;
fontStyle: boolean;
@@ -112,6 +116,9 @@ export interface RichTextPreviewProps {
onChangeType: OnChangeTypeEnum;
spellCheck: boolean;
customFonts: CustomFontsPreviewType[];
+ imageSource: {} | { caption: string } | { type: string } | null;
+ imageSourceContent: { widgetCount: number; renderer: ComponentType<{ children: ReactNode; caption?: string }> };
+ enableDefaultUpload: boolean;
toolbarConfig: ToolbarConfigEnum;
history: boolean;
fontStyle: boolean;