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 ? ( - {defaultValue.alt} - ) : ( - +
+ {!disableEmbed && ( +
+
    +
  • setActiveTab("general")} + > + General +
  • +
  • { + setActiveTab("embed"); + e.stopPropagation(); + e.preventDefault(); + }} + > + Attachments +
  • +
+
)} - - - - - - - px - - - - px - - onSubmit(formState)} onClose={onClose}> +
+ + + {defaultValue?.src ? ( + {defaultValue.alt} + ) : formState.entityGuid && selectedImageEntity ? ( +
+ {selectedImageEntity.id} + + + +
+ ) : enableDefaultUpload ? ( + + ) : undefined} +
+ + + + +
+
+ + px +
+
+ +
+
+ + px +
+
+
+ + + + onSubmit(formState)} onClose={onClose}> +
+ +
{imageSourceContent}
+
+
+
); 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 `\n`; + return ``; } - // modified by web-content: adding new line \n - return `\n${convertListHTML([], lastIndent - 1, types)}`; + return `${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 `\n${convertListHTML(items, lastIndent - 1, types)}`; + return `${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;