From 413ad680a9d8d63526f4a5ccacba6cadf424adb8 Mon Sep 17 00:00:00 2001 From: yongyan Date: Mon, 29 Jun 2026 18:07:14 +0800 Subject: [PATCH] feat(rich-text): store Quill Delta alongside HTML --- .../rich-text-web/CHANGELOG.md | 4 ++ .../src/RichText.editorConfig.ts | 10 +++- .../rich-text-web/src/RichText.tsx | 3 +- .../rich-text-web/src/RichText.xml | 11 ++++ .../__tests__/RichText.editorConfig.spec.ts | 36 +++++++++++++ .../src/__tests__/RichText.spec.tsx | 1 + .../src/components/EditorWrapper.tsx | 52 ++++++++++++++----- .../src/store/useActionEvents.ts | 21 +++++--- .../utils/__tests__/deltaPersistence.spec.ts | 45 ++++++++++++++++ .../src/utils/deltaEditorConfig.ts | 19 +++++++ .../src/utils/deltaPersistence.ts | 33 ++++++++++++ .../rich-text-web/typings/RichTextProps.d.ts | 4 ++ 12 files changed, 217 insertions(+), 22 deletions(-) create mode 100644 packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.editorConfig.spec.ts create mode 100644 packages/pluggableWidgets/rich-text-web/src/utils/__tests__/deltaPersistence.spec.ts create mode 100644 packages/pluggableWidgets/rich-text-web/src/utils/deltaEditorConfig.ts create mode 100644 packages/pluggableWidgets/rich-text-web/src/utils/deltaPersistence.ts diff --git a/packages/pluggableWidgets/rich-text-web/CHANGELOG.md b/packages/pluggableWidgets/rich-text-web/CHANGELOG.md index 4289f15ca5..9f8ba60403 100644 --- a/packages/pluggableWidgets/rich-text-web/CHANGELOG.md +++ b/packages/pluggableWidgets/rich-text-web/CHANGELOG.md @@ -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 diff --git a/packages/pluggableWidgets/rich-text-web/src/RichText.editorConfig.ts b/packages/pluggableWidgets/rich-text-web/src/RichText.editorConfig.ts index 24a8972d85..c921e7c08d 100644 --- a/packages/pluggableWidgets/rich-text-web/src/RichText.editorConfig.ts +++ b/packages/pluggableWidgets/rich-text-web/src/RichText.editorConfig.ts @@ -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, @@ -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 = [ "history", @@ -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,", "")); diff --git a/packages/pluggableWidgets/rich-text-web/src/RichText.tsx b/packages/pluggableWidgets/rich-text-web/src/RichText.tsx index 283829650c..fb2a6d8082 100644 --- a/packages/pluggableWidgets/rich-text-web/src/RichText.tsx +++ b/packages/pluggableWidgets/rich-text-web/src/RichText.tsx @@ -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); @@ -57,6 +57,7 @@ export default function RichText(props: RichTextContainerProps): ReactElement { /> )} {stringAttribute.validation} + {enableDelta && deltaAttribute ? {deltaAttribute.validation} : null} ); } diff --git a/packages/pluggableWidgets/rich-text-web/src/RichText.xml b/packages/pluggableWidgets/rich-text-web/src/RichText.xml index 8fb841b775..7ffa19a2b8 100644 --- a/packages/pluggableWidgets/rich-text-web/src/RichText.xml +++ b/packages/pluggableWidgets/rich-text-web/src/RichText.xml @@ -14,6 +14,17 @@ + + Enable delta + Persist the raw Quill Delta as JSON into a separate string attribute. + + + Delta attribute + The attribute used to persist the raw Quill Delta JSON. Use an unlimited string data type. + + + + diff --git a/packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.editorConfig.spec.ts b/packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.editorConfig.spec.ts new file mode 100644 index 0000000000..a0f0adb414 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.editorConfig.spec.ts @@ -0,0 +1,36 @@ +import { checkDeltaPersistenceConfiguration, DeltaEditorConfigValues } from "../utils/deltaEditorConfig"; + +describe("delta editor config", () => { + function createPreviewProps(props: Partial): 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([]); + }); +}); 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 1ab4c89bad..59bc8f61ed 100644 --- a/packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.spec.tsx +++ b/packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.spec.tsx @@ -12,6 +12,7 @@ describe("Rich Text", () => { name: "RichText", id: "RichText1", stringAttribute: new EditableValueBuilder().withValue(richTextDefaultValue).build(), + enableDelta: false, preset: "basic", toolbarLocation: "bottom", widthUnit: "percentage", diff --git a/packages/pluggableWidgets/rich-text-web/src/components/EditorWrapper.tsx b/packages/pluggableWidgets/rich-text-web/src/components/EditorWrapper.tsx index 99e7174af3..0735517901 100644 --- a/packages/pluggableWidgets/rich-text-web/src/components/EditorWrapper.tsx +++ b/packages/pluggableWidgets/rich-text-web/src/components/EditorWrapper.tsx @@ -9,6 +9,7 @@ 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"; @@ -16,18 +17,20 @@ 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; -} +}; function EditorWrapperInner(props: EditorWrapperProps): ReactElement { const { id, stringAttribute, + enableDelta, + deltaAttribute, style, className, preset, @@ -53,19 +56,36 @@ function EditorWrapperInner(props: EditorWrapperProps): ReactElement { const globalState = useContext(EditorContext); const isFirstLoad = useRef(false); const quillRef = useRef(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(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, @@ -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"; diff --git a/packages/pluggableWidgets/rich-text-web/src/store/useActionEvents.ts b/packages/pluggableWidgets/rich-text-web/src/store/useActionEvents.ts index 0c2eab3408..546bd12163 100644 --- a/packages/pluggableWidgets/rich-text-web/src/store/useActionEvents.ts +++ b/packages/pluggableWidgets/rich-text-web/src/store/useActionEvents.ts @@ -10,7 +10,7 @@ type UseActionEventsReturnValue = { interface useActionEventsProps extends Pick< RichTextContainerProps, - "onFocus" | "onBlur" | "onChange" | "onChangeType" + "onFocus" | "onBlur" | "onChange" | "onChangeType" | "enableDelta" > { quill?: Quill | null; } @@ -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(""); return useMemo(() => { @@ -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 => { @@ -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); @@ -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]); } diff --git a/packages/pluggableWidgets/rich-text-web/src/utils/__tests__/deltaPersistence.spec.ts b/packages/pluggableWidgets/rich-text-web/src/utils/__tests__/deltaPersistence.spec.ts new file mode 100644 index 0000000000..9259eab241 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/src/utils/__tests__/deltaPersistence.spec.ts @@ -0,0 +1,45 @@ +import { getEditorPersistenceSnapshot, serializeQuillDelta } from "../deltaPersistence"; + +describe("deltaPersistence", () => { + it("serializes Quill contents as JSON", () => { + const quill = { + getSemanticHTML: () => "

Hello world

", + 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: () => "

Hello

", + getContents: () => ({ ops: [{ insert: "Hello\n" }] }) + }; + + expect(getEditorPersistenceSnapshot(quill, false)).toEqual({ + html: "

Hello

" + }); + }); + + it("returns HTML and Delta JSON when Delta persistence is enabled", () => { + const quill = { + getSemanticHTML: () => "

Hello

", + getContents: () => ({ ops: [{ insert: "Hello\n" }] }) + }; + + expect(getEditorPersistenceSnapshot(quill, true)).toEqual({ + html: "

Hello

", + deltaJson: JSON.stringify({ ops: [{ insert: "Hello\n" }] }) + }); + }); + + it("returns an empty Delta when Quill is not available", () => { + expect(serializeQuillDelta(null)).toBe(JSON.stringify({ ops: [] })); + }); +}); diff --git a/packages/pluggableWidgets/rich-text-web/src/utils/deltaEditorConfig.ts b/packages/pluggableWidgets/rich-text-web/src/utils/deltaEditorConfig.ts new file mode 100644 index 0000000000..5762166206 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/src/utils/deltaEditorConfig.ts @@ -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 []; +} diff --git a/packages/pluggableWidgets/rich-text-web/src/utils/deltaPersistence.ts b/packages/pluggableWidgets/rich-text-web/src/utils/deltaPersistence.ts new file mode 100644 index 0000000000..526a548845 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/src/utils/deltaPersistence.ts @@ -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) + }; +} diff --git a/packages/pluggableWidgets/rich-text-web/typings/RichTextProps.d.ts b/packages/pluggableWidgets/rich-text-web/typings/RichTextProps.d.ts index c3242f3c72..7fbfae6948 100644 --- a/packages/pluggableWidgets/rich-text-web/typings/RichTextProps.d.ts +++ b/packages/pluggableWidgets/rich-text-web/typings/RichTextProps.d.ts @@ -55,6 +55,8 @@ export interface RichTextContainerProps { tabIndex?: number; id: string; stringAttribute: EditableValue; + enableDelta: boolean; + deltaAttribute?: EditableValue; enableStatusBar: boolean; preset: PresetEnum; toolbarLocation: ToolbarLocationEnum; @@ -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;