From 4747b25776575f22ee1cf4b8fbed5655b0a81461 Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Tue, 16 Sep 2025 22:47:45 +0500 Subject: [PATCH 1/2] #1987 introduce delta for editor --- .../src/comps/comps/richTextEditorComp.tsx | 76 ++++++++++++++----- 1 file changed, 56 insertions(+), 20 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/richTextEditorComp.tsx b/client/packages/lowcoder/src/comps/comps/richTextEditorComp.tsx index 64ec62a95..f2cbb5151 100644 --- a/client/packages/lowcoder/src/comps/comps/richTextEditorComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/richTextEditorComp.tsx @@ -171,7 +171,8 @@ const toolbarOptions = [ ]; const childrenMap = { - value: stringExposingStateControl("value"), + value: stringExposingStateControl("value"), + delta: stringExposingStateControl("delta"), hideToolbar: BoolControl, readOnly: BoolControl, autoHeight: withDefault(AutoHeightControl, "fixed"), @@ -194,7 +195,7 @@ interface IProps { hideToolbar: boolean; readOnly: boolean; autoHeight: boolean; - onChange: (value: string) => void; + onChange: (html: string, deltaJSON: string, text: string) => void; $style: RichTextEditorStyleType; contentScrollBar: boolean; tabIndex?: number; @@ -207,6 +208,21 @@ function RichTextEditor(props: IProps) { const [content, setContent] = useState(""); const wrapperRef = useRef(null); const editorRef = useRef(null); + + const getQuill = () => (editorRef.current as any)?.getEditor?.(); + + const tryParseDelta = (v: unknown) => { + if (!v) return null; + if (typeof v === "string") { + try { + const d = JSON.parse(v); + return Array.isArray(d?.ops) ? d : null; + } catch { return null; } + } + if (typeof v === "object" && Array.isArray((v as any).ops)) return v as any; + return null; + }; + const isTypingRef = useRef(0); const debounce = INPUT_DEFAULT_ONCHANGE_DEBOUNCE; @@ -214,8 +230,8 @@ function RichTextEditor(props: IProps) { const originOnChangeRef = useRef(props.onChange); originOnChangeRef.current = props.onChange; - const onChangeRef = useRef( - (v: string) => originOnChangeRef.current?.(v) + const onChangeRef = useRef((html: string, deltaJSON: string, text: string) => + originOnChangeRef.current?.(html, deltaJSON, text) ); // react-quill will not take effect after the placeholder is updated @@ -235,7 +251,7 @@ function RichTextEditor(props: IProps) { (editor.scroll.domNode as HTMLElement).tabIndex = props.tabIndex; } } - }, [props.tabIndex, key]); // Also re-run when key changes due to placeholder update + }, [props.tabIndex, key]); const contains = (parent: HTMLElement, descendant: HTMLElement) => { try { @@ -248,19 +264,31 @@ function RichTextEditor(props: IProps) { return parent.contains(descendant); }; - const handleChange = (value: string) => { - setContent(value); - // props.onChange(value); - onChangeRef.current(value); - }; useEffect(() => { - let finalValue = props.value; - if (!/^<\w+>.+<\/\w+>$/.test(props.value)) { - finalValue = `

${props.value}

`; + const q = getQuill(); + + if (!q) { + const v = props.value ?? ""; + const looksHtml = /<\/?[a-z][\s\S]*>/i.test(v); + setContent(looksHtml ? v : `

${v}

`); + return; } - setContent(finalValue); + + const asDelta = tryParseDelta(props.value); + if (asDelta) { + q.setContents(asDelta, "api"); + const html = q.root?.innerHTML ?? ""; + setContent(html); + return; + } + + const v = props.value ?? ""; + const looksHtml = /<\/?[a-z][\s\S]*>/i.test(v); + const html = looksHtml ? v : `

${v}

`; + setContent(html); }, [props.value]); + const handleClickWrapper = (e: React.MouseEvent) => { // grid item prevents bubbling, quill can't listen to events on document.body, so it can't close the toolbar drop-down box @@ -297,7 +325,13 @@ function RichTextEditor(props: IProps) { value={content} placeholder={props.placeholder} readOnly={props.readOnly} - onChange={handleChange} + onChange={(html, _delta, source, editor) => { + setContent(html); + const quill = editorRef.current?.getEditor?.(); + const fullDelta = quill?.getContents?.() ?? { ops: [] }; + const text = quill?.getText?.() ?? ""; + onChangeRef.current(html, JSON.stringify(fullDelta), text); + }} /> @@ -306,15 +340,16 @@ function RichTextEditor(props: IProps) { const RichTextEditorCompBase = new UICompBuilder(childrenMap, (props) => { const debouncedOnChangeRef = useRef( - debounce((value: string) => { - props.value.onChange(value); + debounce((html: string, deltaJSON: string, text: string) => { + props.value.onChange(html); + props.delta.onChange(deltaJSON); props.onEvent("change"); }, 1000) ); - const handleChange = (value: string) => { - debouncedOnChangeRef.current?.(value); - }; + const handleChange = (html: string, deltaJSON: string, text: string) => { + debouncedOnChangeRef.current?.(html, deltaJSON, text); + }; return ( Date: Wed, 17 Sep 2025 15:27:16 +0500 Subject: [PATCH 2/2] [Fix]: #1986 change event trigger --- .../src/comps/comps/richTextEditorComp.tsx | 43 ++++++++++++------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/richTextEditorComp.tsx b/client/packages/lowcoder/src/comps/comps/richTextEditorComp.tsx index f2cbb5151..a75120511 100644 --- a/client/packages/lowcoder/src/comps/comps/richTextEditorComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/richTextEditorComp.tsx @@ -171,7 +171,7 @@ const toolbarOptions = [ ]; const childrenMap = { - value: stringExposingStateControl("value"), + value: stringExposingStateControl("value"), delta: stringExposingStateControl("delta"), hideToolbar: BoolControl, readOnly: BoolControl, @@ -195,7 +195,7 @@ interface IProps { hideToolbar: boolean; readOnly: boolean; autoHeight: boolean; - onChange: (html: string, deltaJSON: string, text: string) => void; + onChange: (html: string, deltaJSON: string, text: string) => void; $style: RichTextEditorStyleType; contentScrollBar: boolean; tabIndex?: number; @@ -209,6 +209,13 @@ function RichTextEditor(props: IProps) { const wrapperRef = useRef(null); const editorRef = useRef(null); + // know exactly when the editor mounts + const [editorReady, setEditorReady] = useState(false); + const setEditorRef = (node: ReactQuill | null) => { + (editorRef as any).current = node as any; + setEditorReady(!!node); + }; + const getQuill = () => (editorRef.current as any)?.getEditor?.(); const tryParseDelta = (v: unknown) => { @@ -251,7 +258,7 @@ function RichTextEditor(props: IProps) { (editor.scroll.domNode as HTMLElement).tabIndex = props.tabIndex; } } - }, [props.tabIndex, key]); + }, [props.tabIndex, key]); const contains = (parent: HTMLElement, descendant: HTMLElement) => { try { @@ -267,11 +274,7 @@ function RichTextEditor(props: IProps) { useEffect(() => { const q = getQuill(); - if (!q) { - const v = props.value ?? ""; - const looksHtml = /<\/?[a-z][\s\S]*>/i.test(v); - setContent(looksHtml ? v : `

${v}

`); return; } @@ -282,12 +285,11 @@ function RichTextEditor(props: IProps) { setContent(html); return; } - const v = props.value ?? ""; const looksHtml = /<\/?[a-z][\s\S]*>/i.test(v); const html = looksHtml ? v : `

${v}

`; setContent(html); - }, [props.value]); + }, [props.value, editorReady]); const handleClickWrapper = (e: React.MouseEvent) => { @@ -316,7 +318,7 @@ function RichTextEditor(props: IProps) { }> { + const propsRef = useRef(props); + propsRef.current = props; + const debouncedOnChangeRef = useRef( debounce((html: string, deltaJSON: string, text: string) => { - props.value.onChange(html); - props.delta.onChange(deltaJSON); - props.onEvent("change"); - }, 1000) + propsRef.current.value.onChange(html); + propsRef.current.delta.onChange(deltaJSON); + propsRef.current.onEvent("change"); + }, 500) ); - const handleChange = (html: string, deltaJSON: string, text: string) => { - debouncedOnChangeRef.current?.(html, deltaJSON, text); + useEffect(() => { + return () => { + debouncedOnChangeRef.current?.cancel(); }; + }, []); + + const handleChange = (html: string, deltaJSON: string, text: string) => { + debouncedOnChangeRef.current?.(html, deltaJSON, text); + }; return (