diff --git a/packages/pluggableWidgets/rich-text-web/CHANGELOG.md b/packages/pluggableWidgets/rich-text-web/CHANGELOG.md index 9577233fc9..516af6ecfa 100644 --- a/packages/pluggableWidgets/rich-text-web/CHANGELOG.md +++ b/packages/pluggableWidgets/rich-text-web/CHANGELOG.md @@ -6,6 +6,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Fixed + +- We fixed an issue where onblur and onchange when user leave editor events not firing correctly if a focusable element is clicked as change focus user action. + +### Changed + +- We changed Tab keyboard behavior to add indentation instead of exiting focus from editor. +- We changed ` ` mark for empty line in favor for `
` break tag instead. + +### Added + +- We added alt+F11 keyboard shortcut to do focus next, and alt+F10 to focus on toolbar. +- We added shift+enter keyboard shortcut to add `
` break tag. + ## [4.10.0] - 2025-10-02 ### Fixed diff --git a/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js-snapshots/readOnlyModeBasic-chromium-linux.png b/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js-snapshots/readOnlyModeBasic-chromium-linux.png index 853e9dfa08..7ab375569b 100644 Binary files a/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js-snapshots/readOnlyModeBasic-chromium-linux.png and b/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js-snapshots/readOnlyModeBasic-chromium-linux.png differ diff --git a/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js-snapshots/readOnlyModeBordered-chromium-linux.png b/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js-snapshots/readOnlyModeBordered-chromium-linux.png index d10c284759..13fc0d652d 100644 Binary files a/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js-snapshots/readOnlyModeBordered-chromium-linux.png and b/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js-snapshots/readOnlyModeBordered-chromium-linux.png differ diff --git a/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js-snapshots/readOnlyModeReadPanel-chromium-linux.png b/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js-snapshots/readOnlyModeReadPanel-chromium-linux.png index 90755b6f6a..f286c64de9 100644 Binary files a/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js-snapshots/readOnlyModeReadPanel-chromium-linux.png and b/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js-snapshots/readOnlyModeReadPanel-chromium-linux.png differ diff --git a/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js-snapshots/viewCodeDialog-chromium-linux.png b/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js-snapshots/viewCodeDialog-chromium-linux.png index fc969c934f..ad34390f9c 100644 Binary files a/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js-snapshots/viewCodeDialog-chromium-linux.png and b/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js-snapshots/viewCodeDialog-chromium-linux.png differ diff --git a/packages/pluggableWidgets/rich-text-web/package.json b/packages/pluggableWidgets/rich-text-web/package.json index 9867313914..fffecb02c9 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.10.0", + "version": "4.11.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/__tests__/__snapshots__/RichText.spec.tsx.snap b/packages/pluggableWidgets/rich-text-web/src/__tests__/__snapshots__/RichText.spec.tsx.snap index d7096f79bc..3b208a808c 100644 --- a/packages/pluggableWidgets/rich-text-web/src/__tests__/__snapshots__/RichText.spec.tsx.snap +++ b/packages/pluggableWidgets/rich-text-web/src/__tests__/__snapshots__/RichText.spec.tsx.snap @@ -4228,6 +4228,10 @@ exports[`Rich Text renders richtext widget with readonly config 1`] = `
+ `; @@ -4818,7 +4822,7 @@ exports[`Rich Text renders with HTML character count status bar 1`] = ` > - 77 + 62 character diff --git a/packages/pluggableWidgets/rich-text-web/src/components/Editor.tsx b/packages/pluggableWidgets/rich-text-web/src/components/Editor.tsx index 17b0ce936f..c476e1a793 100644 --- a/packages/pluggableWidgets/rich-text-web/src/components/Editor.tsx +++ b/packages/pluggableWidgets/rich-text-web/src/components/Editor.tsx @@ -16,20 +16,14 @@ import { SET_FULLSCREEN_ACTION } from "../store/store"; import "../utils/customPluginRegisters"; import { FontStyleAttributor, formatCustomFonts } from "../utils/formats/fonts"; import "../utils/formats/quill-table-better/assets/css/quill-table-better.scss"; -import QuillTableBetter from "../utils/formats/quill-table-better/quill-table-better"; -import { RESIZE_MODULE_CONFIG } from "../utils/formats/resizeModuleConfig"; +import { getResizeModuleConfig } from "../utils/formats/resizeModuleConfig"; import { ACTION_DISPATCHER } from "../utils/helpers"; +import { getKeyboardBindings } from "../utils/modules/keyboard"; +import { getIndentHandler } from "../utils/modules/toolbarHandlers"; +import MxUploader from "../utils/modules/uploader"; import MxQuill from "../utils/MxQuill"; -import { - enterKeyKeyboardHandler, - exitFullscreenKeyboardHandler, - getIndentHandler, - gotoStatusBarKeyboardHandler, - gotoToolbarKeyboardHandler -} from "./CustomToolbars/toolbarHandlers"; import { useEmbedModal } from "./CustomToolbars/useEmbedModal"; import Dialog from "./ModalDialog/Dialog"; -import MxUploader from "../utils/modules/uploader"; export interface EditorProps extends Pick { @@ -115,26 +109,7 @@ const Editor = forwardRef((props: EditorProps, ref: MutableRefObject(false); const quillRef = useRef(null); - const [isFocus, setIsFocus] = useState(false); - const editorValueRef = useRef(""); + const actionEvents = useActionEvents({ onBlur, onFocus, onChange, onChangeType, quill: quillRef?.current }); const toolbarRef = useRef(null); const [wordCount, setWordCount] = useState(0); @@ -128,34 +127,6 @@ function EditorWrapperInner(props: EditorWrapperProps): ReactElement { // eslint-disable-next-line react-hooks/exhaustive-deps }, [quillRef.current, stringAttribute, calculateCounts, onChange?.isExecuting]); - const onSelectionChange = useCallback( - (range: Range) => { - if (range) { - // User cursor is selecting - if (!isFocus) { - setIsFocus(true); - executeAction(onFocus); - editorValueRef.current = quillRef.current?.getText() || ""; - } - } else { - // Cursor not in the editor - if (isFocus) { - setIsFocus(false); - executeAction(onBlur); - - if (onChangeType === "onLeave") { - if (editorValueRef.current !== quillRef.current?.getText()) { - executeAction(onChange); - } - } - } - } - (quillRef.current?.theme as MendixTheme).updatePicker(range); - }, - - [isFocus, onFocus, onBlur, onChange, onChangeType] - ); - const toolbarId = `widget_${id.replaceAll(".", "_")}_toolbar`; const shouldHideToolbar = (stringAttribute.readOnly && readOnlyStyle !== "text") || toolbarLocation === "hide"; const toolbarPreset = shouldHideToolbar ? [] : createPreset(props); @@ -182,7 +153,8 @@ function EditorWrapperInner(props: EditorWrapperProps): ReactElement { } }} spellCheck={props.spellCheck} - tabIndex={tabIndex} + tabIndex={tabIndex ?? -1} + {...actionEvents} > @@ -220,7 +192,6 @@ function EditorWrapperInner(props: EditorWrapperProps): ReactElement { } toolbarId={shouldHideToolbar ? undefined : toolbarOptions ? toolbarOptions : toolbarId} onTextChange={onTextChange} - onSelectionChange={onSelectionChange} className={"widget-rich-text-container"} readOnly={stringAttribute.readOnly} key={`${toolbarId}_${stringAttribute.readOnly}`} @@ -231,15 +202,19 @@ function EditorWrapperInner(props: EditorWrapperProps): ReactElement { formOrientation={formOrientation} /> - -
+ +
+ {wordCount} {` ${statusBarContent === "wordCount" ? "word" : "character"}`} {wordCount === 1 ? "" : "s"} -
- + +
); } diff --git a/packages/pluggableWidgets/rich-text-web/src/package.xml b/packages/pluggableWidgets/rich-text-web/src/package.xml index ac7ba9fc2b..c61933a443 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/store/useActionEvents.ts b/packages/pluggableWidgets/rich-text-web/src/store/useActionEvents.ts new file mode 100644 index 0000000000..67315da04a --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/src/store/useActionEvents.ts @@ -0,0 +1,57 @@ +import { executeAction } from "@mendix/widget-plugin-platform/framework/execute-action"; +import Quill from "quill"; +import { FocusEvent, useMemo, useRef } from "react"; +import { RichTextContainerProps } from "typings/RichTextProps"; + +type UseActionEventsReturnValue = { + onFocus: (e: FocusEvent) => void; + onBlur: (e: FocusEvent) => void; +}; + +interface useActionEventsProps + extends Pick { + quill?: Quill | null; +} + +function isInternalTarget( + currentTarget: EventTarget & Element, + relatedTarget: (EventTarget & Element) | null +): boolean | undefined { + return ( + currentTarget?.contains(relatedTarget) || + currentTarget?.ownerDocument.querySelector(".widget-rich-text-modal-body")?.contains(relatedTarget) + ); +} + +export function useActionEvents(props: useActionEventsProps): UseActionEventsReturnValue { + const editorValueRef = useRef(""); + return useMemo(() => { + return { + onFocus: (e: FocusEvent): void => { + const { relatedTarget, currentTarget } = e; + if (!isInternalTarget(currentTarget, relatedTarget)) { + executeAction(props.onFocus); + editorValueRef.current = props.quill?.getText() || ""; + } + }, + onBlur: (e: FocusEvent): void => { + const { relatedTarget, currentTarget } = e; + if (!isInternalTarget(currentTarget, relatedTarget)) { + 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) { + executeAction(props.onChange); + editorValueRef.current = currentText; + } + } else { + executeAction(props.onChange); + } + } + } + } + }; + }, [props.onFocus, props.quill, props.onBlur, props.onChangeType, props.onChange]); +} diff --git a/packages/pluggableWidgets/rich-text-web/src/ui/RichText.scss b/packages/pluggableWidgets/rich-text-web/src/ui/RichText.scss index dbd4738154..36103a40c4 100644 --- a/packages/pluggableWidgets/rich-text-web/src/ui/RichText.scss +++ b/packages/pluggableWidgets/rich-text-web/src/ui/RichText.scss @@ -88,9 +88,8 @@ $rte-brand-primary: #264ae5; display: none; } - .widget-rich-text-footer { + &-footer { align-items: center; - border-top: 1px solid var(--border-color-default, $rte-border-color-default); color: var(--font-color-detail); display: flex; flex: 0 0 auto; @@ -101,6 +100,14 @@ $rte-brand-primary: #264ae5; position: relative; text-transform: none; justify-content: end; + + &:not(.hide-status-bar) { + border-top: 1px solid var(--border-color-default, $rte-border-color-default); + } + + &:focus-within { + border-top: 1px solid var(--form-input-border-focus-color, var(--brand-primary, $rte-brand-primary)); + } } &.editor-readPanel { diff --git a/packages/pluggableWidgets/rich-text-web/src/utils/MxQuill.ts b/packages/pluggableWidgets/rich-text-web/src/utils/MxQuill.ts index 6a637f0517..cdff902b9d 100644 --- a/packages/pluggableWidgets/rich-text-web/src/utils/MxQuill.ts +++ b/packages/pluggableWidgets/rich-text-web/src/utils/MxQuill.ts @@ -174,7 +174,7 @@ function convertHTML(blot: Blot, index: number, length: number, isRoot = false): } if (blot instanceof TextBlot) { const escapedText = escapeText(blot.value().slice(index, index + length)); - return escapedText.replaceAll(" ", " "); + return escapedText; } 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 df383fcf3c..3f10685fc0 100644 --- a/packages/pluggableWidgets/rich-text-web/src/utils/customPluginRegisters.ts +++ b/packages/pluggableWidgets/rich-text-web/src/utils/customPluginRegisters.ts @@ -6,6 +6,7 @@ import CustomListItem from "./formats/customList"; import CustomLink from "./formats/link"; import CustomVideo from "./formats/video"; import CustomImage from "./formats/image"; +import SoftBreak from "./formats/softBreak"; import Button from "./formats/button"; import { Attributor } from "parchment"; const direction = Quill.import("attributors/style/direction") as Attributor; @@ -18,6 +19,7 @@ import MxUploader from "./modules/uploader"; import MxBlock from "./formats/block"; import CustomClipboard from "./modules/clipboard"; import { WhiteSpaceStyle } from "./formats/whiteSpace"; + class Empty { doSomething(): string { return ""; @@ -33,6 +35,7 @@ Quill.register(WhiteSpaceStyle, true); Quill.register(CustomLink, true); Quill.register(CustomVideo, true); Quill.register(CustomImage, true); +Quill.register({ "formats/softbreak": SoftBreak }, true); Quill.register(direction, true); Quill.register(alignment, true); Quill.register(IndentLeftStyle, true); diff --git a/packages/pluggableWidgets/rich-text-web/src/utils/formats/block.ts b/packages/pluggableWidgets/rich-text-web/src/utils/formats/block.ts index 2a7460d457..802cc43001 100644 --- a/packages/pluggableWidgets/rich-text-web/src/utils/formats/block.ts +++ b/packages/pluggableWidgets/rich-text-web/src/utils/formats/block.ts @@ -5,9 +5,9 @@ class MxBlock extends Block { // 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, " "); + return this.domNode.outerHTML.replace(/
/g, "
"); } else if (this.domNode.childElementCount === 0 && this.domNode.textContent?.trim() === "") { - this.domNode.innerHTML = " "; + this.domNode.innerHTML = "
"; return this.domNode.outerHTML; } else { return this.domNode.outerHTML; 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 72f64d6dfb..c6a8bf24f7 100644 --- a/packages/pluggableWidgets/rich-text-web/src/utils/formats/resizeModuleConfig.ts +++ b/packages/pluggableWidgets/rich-text-web/src/utils/formats/resizeModuleConfig.ts @@ -62,3 +62,10 @@ export const RESIZE_MODULE_CONFIG = { } } }; + +export function getResizeModuleConfig(isReadOnly?: boolean): Record | undefined { + if (isReadOnly) { + return {}; + } + return { resize: RESIZE_MODULE_CONFIG }; +} diff --git a/packages/pluggableWidgets/rich-text-web/src/utils/formats/softBreak.ts b/packages/pluggableWidgets/rich-text-web/src/utils/formats/softBreak.ts new file mode 100644 index 0000000000..c37942ef43 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/src/utils/formats/softBreak.ts @@ -0,0 +1,17 @@ +// import { BlockEmbed } from "quill/blots/block"; +import { EmbedBlot } from "parchment"; +/** + * custom video link handler, allowing width and height config + */ +class SoftBreak extends EmbedBlot { + static create(_value: unknown): Element { + const node = super.create() as HTMLElement; + return node; + } +} + +// SoftBreak.scope = Scope.INLINE_BLOT; +SoftBreak.blotName = "softbreak"; +SoftBreak.tagName = "BR"; + +export default SoftBreak; diff --git a/packages/pluggableWidgets/rich-text-web/src/utils/modules/keyboard.ts b/packages/pluggableWidgets/rich-text-web/src/utils/modules/keyboard.ts new file mode 100644 index 0000000000..cb5706e02d --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/src/utils/modules/keyboard.ts @@ -0,0 +1,67 @@ +import { + addIndentText, + enterKeyKeyboardHandler, + exitFullscreenKeyboardHandler, + gotoStatusBarKeyboardHandler, + gotoToolbarKeyboardHandler, + moveIndent, + moveOutdent, + movePrevFocus, + shiftEnterKeyKeyboardHandler +} from "./toolbarHandlers"; +import QuillTableBetter from "../formats/quill-table-better/quill-table-better"; + +export function getKeyboardBindings(): Record { + const defaultBindings: Record = { + enter: { + key: "Enter", + handler: enterKeyKeyboardHandler + }, + shiftEnter: { + key: "Enter", + shiftKey: true, + collapsed: true, + handler: shiftEnterKeyKeyboardHandler + }, + focusTab: { + key: "F10", + altKey: true, + collapsed: true, + handler: gotoToolbarKeyboardHandler + }, + shiftTab: { + key: "Tab", + shiftKey: true, + handler: movePrevFocus + }, + outdent: { + key: "Tab", + shiftKey: true, + format: ["blockquote", "indent", "list"], + // highlight tab or tab at beginning of list, indent or blockquote + handler: moveOutdent + }, + indent: { + // highlight tab or tab at beginning of list, indent or blockquote + key: "Tab", + format: ["blockquote", "indent", "list"], + handler: moveIndent + }, + nextFocusTab: { + key: "F11", + altKey: true, + collapsed: true, + handler: gotoStatusBarKeyboardHandler + }, + escape: { + key: "Escape", + handler: exitFullscreenKeyboardHandler + }, + tab: { + key: "Tab", + handler: addIndentText + }, + ...QuillTableBetter.keyboardBindings + }; + return defaultBindings; +} diff --git a/packages/pluggableWidgets/rich-text-web/src/components/CustomToolbars/toolbarHandlers.ts b/packages/pluggableWidgets/rich-text-web/src/utils/modules/toolbarHandlers.ts similarity index 52% rename from packages/pluggableWidgets/rich-text-web/src/components/CustomToolbars/toolbarHandlers.ts rename to packages/pluggableWidgets/rich-text-web/src/utils/modules/toolbarHandlers.ts index beb309c033..d8fdf8dcad 100644 --- a/packages/pluggableWidgets/rich-text-web/src/components/CustomToolbars/toolbarHandlers.ts +++ b/packages/pluggableWidgets/rich-text-web/src/utils/modules/toolbarHandlers.ts @@ -4,9 +4,15 @@ import { MutableRefObject } from "react"; import { Range } from "quill/core/selection"; import Keyboard, { Context } from "quill/modules/keyboard"; import { Scope } from "parchment"; -import { ACTION_DISPATCHER } from "../../utils/helpers"; +import { ACTION_DISPATCHER } from "../helpers"; import { SET_FULLSCREEN_ACTION } from "../../store/store"; +function returnWithStopPropagation(context: Context): boolean { + context.event.stopPropagation(); + context.event.preventDefault(); + return true; +} + /** * give custom indent handler to use our custom "indent-left" and "indent-right" formats (formats/indent.ts) */ @@ -70,13 +76,64 @@ export function enterKeyKeyboardHandler(this: Keyboard, range: Range, context: C }); } +export function shiftEnterKeyKeyboardHandler(this: Keyboard, range: Range, context: Context): any { + if (context.format.table) { + return true; + } + this.quill.insertEmbed(range.index, "softbreak", true, Quill.sources.USER); + this.quill.setSelection(range.index + 1, Quill.sources.SILENT); + return false; +} + +export function movePrevFocus(this: Keyboard, range: Range, context: Context): any { + if (context.format.table) { + return returnWithStopPropagation(context); + } else if (context.collapsed) { + if (context.format.indent || context.format.list || context.format.blockquote) { + return returnWithStopPropagation(context); + } + } + + gotoToolbarKeyboardHandler.call(this, range, context); + return returnWithStopPropagation(context); +} + +// Copied from https://github.com/slab/quill/blob/539cbffd0a13b18e9c65eb84dd35e6596e403158/packages/quill/src/modules/keyboard.ts#L372 +// with added stopPropagation and preventDefault +export function moveOutdent(this: Keyboard, _range: Range, context: Context): any { + if (context.collapsed && context.offset !== 0) { + return returnWithStopPropagation(context); + } + this.quill.format("indent", "-1", Quill.sources.USER); + return !returnWithStopPropagation(context); +} + +// Copied from https://github.com/slab/quill/blob/539cbffd0a13b18e9c65eb84dd35e6596e403158/packages/quill/src/modules/keyboard.ts#L372 +// with added stopPropagation and preventDefault +export function moveIndent(this: Keyboard, _range: Range, context: Context): any { + if (context.collapsed && context.offset !== 0) { + return returnWithStopPropagation(context); + } + this.quill.format("indent", "+1", Quill.sources.USER); + return !returnWithStopPropagation(context); +} + // focus to first toolbar button -export function gotoToolbarKeyboardHandler(this: Keyboard, _range: Range, _context: Context): void { +export function gotoToolbarKeyboardHandler(this: Keyboard, _range: Range, context: Context): any { + if (context.format.table) { + return true; + } + const toolbar = this.quill.container.parentElement?.parentElement?.querySelector(".widget-rich-text-toolbar"); - (toolbar?.querySelector(".ql-formats button") as HTMLElement)?.focus(); + if (toolbar) { + (toolbar?.querySelector(".ql-formats button") as HTMLElement)?.focus(); + } else { + // "widget-rich-text form-control" + this.quill.container.parentElement?.parentElement?.parentElement?.focus(); + } } -// focus to status bar button (exit editor) +// move to next element focus : status bar button (exit editor) export function gotoStatusBarKeyboardHandler(this: Keyboard, _range: Range, context: Context): boolean | void { if (context.format.table) { return true; @@ -86,7 +143,27 @@ export function gotoStatusBarKeyboardHandler(this: Keyboard, _range: Range, cont if (statusBar) { (statusBar as HTMLElement)?.focus(); } else { - this.quill.blur(); + // "widget-rich-text form-control" + this.quill.container.parentElement?.parentElement?.parentElement?.focus(); + } +} + +// default quill tab handler +// https://github.com/slab/quill/blob/539cbffd0a13b18e9c65eb84dd35e6596e403158/packages/quill/src/modules/keyboard.ts#L412 +// but modified to add stopPropagation and preventDefault +export function addIndentText(this: Keyboard, range: Range, context: Context): boolean | void { + if (context.format.table) { + return true; + } + if (context.collapsed && context.offset === 0) { + return moveIndent.call(this, range, context); + } else { + this.quill.history.cutoff(); + const delta = new Delta().retain(range.index).delete(range.length).insert("\t"); + this.quill.updateContents(delta, Quill.sources.USER); + this.quill.history.cutoff(); + this.quill.setSelection(range.index + 1, Quill.sources.SILENT); + return returnWithStopPropagation(context); } }