Skip to content
Closed
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
4 changes: 4 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,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

## [Unreleased]

### Added

- We added an optional Delta persistence setting that stores raw Quill Delta JSON in a separate string attribute while keeping the existing HTML value attribute unchanged.

## [4.12.0] - 2026-04-22

### Added
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { hidePropertiesIn, hidePropertyIn, Properties } from "@mendix/pluggable-widgets-tools";
import { hidePropertiesIn, hidePropertyIn, Problem, Properties } from "@mendix/pluggable-widgets-tools";
import {
container,
dropzone,
Expand All @@ -8,6 +8,7 @@ import {
import { RichTextPreviewProps } from "typings/RichTextProps";
import RichTextPreviewSVGDark from "./assets/rich-text-preview-dark.svg";
import RichTextPreviewSVGLight from "./assets/rich-text-preview-light.svg";
import { checkDeltaPersistenceConfiguration } from "./utils/deltaEditorConfig";

const toolbarGroupKeys: Array<keyof RichTextPreviewProps> = [
"history",
Expand Down Expand Up @@ -71,9 +72,16 @@ export function getProperties(values: RichTextPreviewProps, defaultProperties: P
if (values.enableStatusBar === false) {
hidePropertyIn(defaultProperties, values, "statusBarContent");
}
if (!values.enableDelta) {
hidePropertyIn(defaultProperties, values, "deltaAttribute");
}
return defaultProperties;
}

export function check(values: RichTextPreviewProps): Problem[] {
return checkDeltaPersistenceConfiguration(values);
}

export function getPreview(props: RichTextPreviewProps, isDarkMode: boolean): StructurePreviewProps {
const variant = isDarkMode ? RichTextPreviewSVGDark : RichTextPreviewSVGLight;
const doc = decodeURIComponent(variant.replace("data:image/svg+xml,", ""));
Expand Down
3 changes: 2 additions & 1 deletion packages/pluggableWidgets/rich-text-web/src/RichText.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import "./ui/RichText.scss";
import { constructWrapperStyle } from "./utils/helpers";

export default function RichText(props: RichTextContainerProps): ReactElement {
const { stringAttribute, readOnlyStyle } = props;
const { stringAttribute, readOnlyStyle, enableDelta, deltaAttribute } = props;

const wrapperStyle = constructWrapperStyle(props);
const [isIncubator, setIsIncubator] = useState(true);
Expand Down Expand Up @@ -57,6 +57,7 @@ export default function RichText(props: RichTextContainerProps): ReactElement {
/>
)}
<ValidationAlert>{stringAttribute.validation}</ValidationAlert>
{enableDelta && deltaAttribute ? <ValidationAlert>{deltaAttribute.validation}</ValidationAlert> : null}
</Fragment>
);
}
11 changes: 11 additions & 0 deletions packages/pluggableWidgets/rich-text-web/src/RichText.xml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@
<attributeType name="String" />
</attributeTypes>
</property>
<property key="enableDelta" type="boolean" defaultValue="false">
<caption>Enable delta</caption>
<description>Persist the raw Quill Delta as JSON into a separate string attribute.</description>
</property>
<property key="deltaAttribute" type="attribute" required="false">
<caption>Delta attribute</caption>
<description>The attribute used to persist the raw Quill Delta JSON. Use an unlimited string data type.</description>
<attributeTypes>
<attributeType name="String" />
</attributeTypes>
</property>
</propertyGroup>
<propertyGroup caption="General">
<systemProperty key="Label" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { checkDeltaPersistenceConfiguration, DeltaEditorConfigValues } from "../utils/deltaEditorConfig";

describe("delta editor config", () => {
function createPreviewProps(props: Partial<DeltaEditorConfigValues>): DeltaEditorConfigValues {
return {
enableDelta: false,
deltaAttribute: "",
...props
};
}

it("returns no Delta error when Delta persistence is disabled", () => {
expect(
checkDeltaPersistenceConfiguration(createPreviewProps({ enableDelta: false, deltaAttribute: "" }))
).toEqual([]);
});

it("returns a Delta attribute error when Delta persistence is enabled without an attribute", () => {
expect(
checkDeltaPersistenceConfiguration(createPreviewProps({ enableDelta: true, deltaAttribute: "" }))
).toEqual([
{
property: "deltaAttribute",
message: "Select a string attribute for Delta persistence, or disable Delta persistence."
}
]);
});

it("returns no Delta error when Delta persistence is enabled with an attribute", () => {
expect(
checkDeltaPersistenceConfiguration(
createPreviewProps({ enableDelta: true, deltaAttribute: "MyModule.Entity.delta" })
)
).toEqual([]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ describe("Rich Text", () => {
name: "RichText",
id: "RichText1",
stringAttribute: new EditableValueBuilder<string>().withValue(richTextDefaultValue).build(),
enableDelta: false,
preset: "basic",
toolbarLocation: "bottom",
widthUnit: "percentage",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,25 +9,28 @@ import { CSSProperties, ReactElement, useCallback, useContext, useEffect, useRef
import { RichTextContainerProps } from "typings/RichTextProps";
import { EditorContext, EditorProvider } from "../store/EditorProvider";
import { useActionEvents } from "../store/useActionEvents";
import { EditorPersistenceSnapshot, getEditorPersistenceSnapshot } from "../utils/deltaPersistence";
import MendixTheme from "../utils/themes/mxTheme";
import { MxQuillModulesOptions } from "../utils/MxQuill";
import { createPreset } from "./CustomToolbars/presets";
import Editor from "./Editor";
import { StickySentinel } from "./StickySentinel";
import Toolbar from "./Toolbar";

export interface EditorWrapperProps extends RichTextContainerProps {
export type EditorWrapperProps = RichTextContainerProps & {
editorHeight?: string | number;
editorWidth?: string | number;
style?: CSSProperties;
className?: string;
toolbarOptions?: Array<string | string[] | { [k: string]: any }>;
}
};

function EditorWrapperInner(props: EditorWrapperProps): ReactElement {
const {
id,
stringAttribute,
enableDelta,
deltaAttribute,
style,
className,
preset,
Expand All @@ -53,19 +56,36 @@ function EditorWrapperInner(props: EditorWrapperProps): ReactElement {
const globalState = useContext(EditorContext);
const isFirstLoad = useRef<boolean>(false);
const quillRef = useRef<Quill>(null);
const actionEvents = useActionEvents({ onBlur, onFocus, onChange, onChangeType, quill: quillRef?.current });
const actionEvents = useActionEvents({
onBlur,
onFocus,
onChange,
onChangeType,
enableDelta,
quill: quillRef?.current
});
const toolbarRef = useRef<HTMLDivElement>(null);
const [wordCount, setWordCount] = useState(0);

const { isFullscreen } = globalState;

const [setAttributeValueDebounce] = useDebounceWithStatus(
(string?: string) => {
if (stringAttribute.value !== string) {
stringAttribute.setValue(string);
if (onChangeType === "onDataChange") {
executeAction(onChange);
}
({ html, deltaJson }: EditorPersistenceSnapshot) => {
const htmlChanged = stringAttribute.value !== html;
const shouldPersistDelta =
enableDelta && deltaAttribute && !deltaAttribute.readOnly && deltaJson !== undefined;
const deltaChanged = Boolean(shouldPersistDelta && deltaAttribute.value !== deltaJson);

if (htmlChanged) {
stringAttribute.setValue(html);
}

if (deltaChanged && deltaJson !== undefined) {
deltaAttribute?.setValue(deltaJson);
}

if ((htmlChanged || deltaChanged) && onChangeType === "onDataChange") {
executeAction(onChange);
}
},
200,
Expand Down Expand Up @@ -116,12 +136,18 @@ function EditorWrapperInner(props: EditorWrapperProps): ReactElement {
}, [quillRef.current]);

const onTextChange = useCallback(() => {
const semanticHTML = quillRef.current?.getSemanticHTML() || "";
if (stringAttribute.value !== semanticHTML) {
setAttributeValueDebounce(semanticHTML);
// HTML remains the source of truth for loading editor content. Delta is a parallel representation.
const snapshot = getEditorPersistenceSnapshot(quillRef.current, enableDelta);
const htmlChanged = stringAttribute.value !== snapshot.html;
const canPersistDelta = enableDelta && deltaAttribute && !deltaAttribute.readOnly;
const deltaChanged =
canPersistDelta && snapshot.deltaJson !== undefined && deltaAttribute.value !== snapshot.deltaJson;

if (htmlChanged || deltaChanged) {
setAttributeValueDebounce(snapshot);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [quillRef.current, stringAttribute, calculateCounts]);
}, [quillRef.current, stringAttribute, deltaAttribute, enableDelta, calculateCounts]);

const toolbarId = `widget_${id.replaceAll(".", "_")}_toolbar`;
const shouldHideToolbar = (stringAttribute.readOnly && readOnlyStyle !== "text") || toolbarLocation === "hide";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ type UseActionEventsReturnValue = {

interface useActionEventsProps extends Pick<
RichTextContainerProps,
"onFocus" | "onBlur" | "onChange" | "onChangeType"
"onFocus" | "onBlur" | "onChange" | "onChangeType" | "enableDelta"
> {
quill?: Quill | null;
}
Expand All @@ -25,6 +25,14 @@ function isInternalTarget(
);
}

function getChangeSnapshot(quill: Quill | null | undefined, enableDelta: boolean): string {
if (!quill) {
return "";
}

return enableDelta ? JSON.stringify(quill.getContents()) : quill.getText();
}

export function useActionEvents(props: useActionEventsProps): UseActionEventsReturnValue {
const editorValueRef = useRef<string>("");
return useMemo(() => {
Expand All @@ -33,7 +41,7 @@ export function useActionEvents(props: useActionEventsProps): UseActionEventsRet
const { relatedTarget, currentTarget } = e;
if (!isInternalTarget(currentTarget, relatedTarget)) {
executeAction(props.onFocus);
editorValueRef.current = props.quill?.getText() || "";
editorValueRef.current = getChangeSnapshot(props.quill, props.enableDelta);
}
},
onBlur: (e: FocusEvent): void => {
Expand All @@ -42,11 +50,10 @@ export function useActionEvents(props: useActionEventsProps): UseActionEventsRet
executeAction(props.onBlur);
if (props.onChangeType === "onLeave") {
if (props.quill) {
// validate if the text really changed
const currentText = props.quill.getText();
if (currentText !== editorValueRef.current) {
const currentSnapshot = getChangeSnapshot(props.quill, props.enableDelta);
if (currentSnapshot !== editorValueRef.current) {
executeAction(props.onChange);
editorValueRef.current = currentText;
editorValueRef.current = currentSnapshot;
}
} else {
executeAction(props.onChange);
Expand All @@ -55,5 +62,5 @@ export function useActionEvents(props: useActionEventsProps): UseActionEventsRet
}
}
};
}, [props.onFocus, props.quill, props.onBlur, props.onChangeType, props.onChange]);
}, [props.onFocus, props.quill, props.onBlur, props.onChangeType, props.onChange, props.enableDelta]);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { getEditorPersistenceSnapshot, serializeQuillDelta } from "../deltaPersistence";

describe("deltaPersistence", () => {
it("serializes Quill contents as JSON", () => {
const quill = {
getSemanticHTML: () => "<p>Hello <strong>world</strong></p>",
getContents: () => ({
ops: [{ insert: "Hello " }, { insert: "world", attributes: { bold: true } }, { insert: "\n" }]
})
};

expect(serializeQuillDelta(quill)).toBe(
JSON.stringify({
ops: [{ insert: "Hello " }, { insert: "world", attributes: { bold: true } }, { insert: "\n" }]
})
);
});

it("returns only HTML when Delta persistence is disabled", () => {
const quill = {
getSemanticHTML: () => "<p>Hello</p>",
getContents: () => ({ ops: [{ insert: "Hello\n" }] })
};

expect(getEditorPersistenceSnapshot(quill, false)).toEqual({
html: "<p>Hello</p>"
});
});

it("returns HTML and Delta JSON when Delta persistence is enabled", () => {
const quill = {
getSemanticHTML: () => "<p>Hello</p>",
getContents: () => ({ ops: [{ insert: "Hello\n" }] })
};

expect(getEditorPersistenceSnapshot(quill, true)).toEqual({
html: "<p>Hello</p>",
deltaJson: JSON.stringify({ ops: [{ insert: "Hello\n" }] })
});
});

it("returns an empty Delta when Quill is not available", () => {
expect(serializeQuillDelta(null)).toBe(JSON.stringify({ ops: [] }));
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Problem } from "@mendix/pluggable-widgets-tools";

export type DeltaEditorConfigValues = {
enableDelta?: boolean;
deltaAttribute?: string;
};

export function checkDeltaPersistenceConfiguration(values: DeltaEditorConfigValues): Problem[] {
if (values.enableDelta && !values.deltaAttribute) {
return [
{
property: "deltaAttribute",
message: "Select a string attribute for Delta persistence, or disable Delta persistence."
}
];
}

return [];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
export interface SerializableQuill {
getSemanticHTML(): string;
getContents(): unknown;
}

export interface EditorPersistenceSnapshot {
html: string;
deltaJson?: string;
}

export function serializeQuillDelta(quill: SerializableQuill | null | undefined): string {
if (!quill) {
return JSON.stringify({ ops: [] });
}

return JSON.stringify(quill.getContents());
}

export function getEditorPersistenceSnapshot(
quill: SerializableQuill | null | undefined,
enableDelta: boolean
): EditorPersistenceSnapshot {
const html = quill?.getSemanticHTML() ?? "";

if (!enableDelta) {
return { html };
}

return {
html,
deltaJson: serializeQuillDelta(quill)
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ export interface RichTextContainerProps {
tabIndex?: number;
id: string;
stringAttribute: EditableValue<string>;
enableDelta: boolean;
deltaAttribute?: EditableValue<string>;
enableStatusBar: boolean;
preset: PresetEnum;
toolbarLocation: ToolbarLocationEnum;
Expand Down Expand Up @@ -105,6 +107,8 @@ export interface RichTextPreviewProps {
renderMode: "design" | "xray" | "structure";
translate: (text: string) => string;
stringAttribute: string;
enableDelta: boolean;
deltaAttribute: string;
enableStatusBar: boolean;
preset: PresetEnum;
toolbarLocation: ToolbarLocationEnum;
Expand Down
Loading