communication-react-19
Version:
React library for building modern communication user experiences utilizing Azure Communication Services (React 19 compatible fork)
380 lines • 18.9 kB
JavaScript
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
import React, { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
import { richTextEditorWrapperStyle, richTextEditorStyle } from '../styles/RichTextEditor.styles';
import { useTheme } from '../../theming';
import { isDarkThemed } from '../../theming/themeUtils';
import CopyPastePlugin from './Plugins/CopyPastePlugin';
import { createModelFromHtml, Editor, exportContent } from 'roosterjs-content-model-core';
import { createBr, createEmptyModel, createParagraph, createSelectionMarker, setSelection } from 'roosterjs-content-model-dom';
import { KeyboardInputPlugin } from './Plugins/KeyboardInputPlugin';
import { AutoFormatPlugin, EditPlugin, PastePlugin, ShortcutPlugin, DefaultSanitizers } from 'roosterjs-content-model-plugins';
import { UpdateContentPlugin, UpdateEvent } from './Plugins/UpdateContentPlugin';
import { RichTextToolbar } from './Toolbar/RichTextToolbar';
import { RichTextToolbarPlugin } from './Plugins/RichTextToolbarPlugin';
import { ContextMenuPlugin } from './Plugins/ContextMenuPlugin';
import { TableEditContextMenuProvider } from './Plugins/TableEditContextMenuProvider';
import { borderApplier, dataSetApplier } from '../utils/RichTextEditorUtils';
/* @conditional-compile-remove(rich-text-editor-image-upload) */
import { getPreviousInlineImages, getRemovedInlineImages, removeLocalBlobs, cleanAllLocalBlobs } from '../utils/RichTextEditorUtils';
import { ContextualMenu } from '@fluentui/react';
import { PlaceholderPlugin } from './Plugins/PlaceholderPlugin';
import { getFormatState, setDirection } from 'roosterjs-content-model-api';
import UndoRedoPlugin from './Plugins/UndoRedoPlugin';
/**
* A component to wrap RoosterJS Rich Text Editor.
*
* @beta
*/
export const RichTextEditor = React.forwardRef((props, ref) => {
const { initialContent, onChange, placeholderText, strings, showRichTextEditorFormatting, autoFocus, onKeyDown, onCompositionUpdate, onContentModelUpdate, contentModel,
/* @conditional-compile-remove(rich-text-editor-image-upload) */
onPaste,
/* @conditional-compile-remove(rich-text-editor-image-upload) */
onInsertInlineImage } = props;
const editor = useRef(null);
const editorDiv = useRef(null);
const theme = useTheme();
const [contextMenuProps, setContextMenuProps] = useState(null);
const previousThemeDirection = useRef(themeDirection(theme));
/* @conditional-compile-remove(rich-text-editor-image-upload) */
// This will be set when the editor is initialized and when the content is updated.
const [previousInlineImages, setPreviousInlineImages] = useState([]);
/* @conditional-compile-remove(rich-text-editor-image-upload) */
const [inlineImageLocalBlobs, setInlineImageLocalBlobs] = useState({});
/* @conditional-compile-remove(rich-text-editor-image-upload) */
useEffect(() => {
return () => {
// Cleanup Local Blob URLs when the component is unmounted
cleanAllLocalBlobs(inlineImageLocalBlobs);
};
// This effect should only run once when the component is unmounted, so we don't need to add any dependencies
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
var _a;
if (editor.current) {
if (!showRichTextEditorFormatting) {
(_a = editor.current) === null || _a === void 0 ? void 0 : _a.focus();
}
}
}, [showRichTextEditorFormatting]);
useImperativeHandle(ref, () => {
return {
focus() {
if (editor.current) {
editor.current.focus();
}
},
setEmptyContent() {
/* @conditional-compile-remove(rich-text-editor-image-upload) */
setPreviousInlineImages([]);
/* @conditional-compile-remove(rich-text-editor-image-upload) */
cleanAllLocalBlobs(inlineImageLocalBlobs);
if (editor.current) {
// remove all content from the editor and update the model
// ContentChanged event will be sent by RoosterJS automatically
editor.current.formatContentModel((model) => {
// Create a new empty paragraph with selection marker
// this is needed for correct processing of images after the content is deleted
const newModel = createEmptyModel();
model.blocks = newModel.blocks;
return true;
});
//reset content model
onContentModelUpdate && onContentModelUpdate(editor.current.getContentModelCopy('disconnected'));
}
},
getPlainContent() {
if (editor.current) {
return exportContent(editor.current, 'PlainTextFast');
}
else {
return undefined;
}
}
};
}, [/* @conditional-compile-remove(rich-text-editor-image-upload) */ inlineImageLocalBlobs, onContentModelUpdate]);
const toolbarPlugin = React.useMemo(() => {
return new RichTextToolbarPlugin();
}, []);
const placeholderPlugin = useMemo(() => {
var _a;
const textColor = (_a = theme.palette) === null || _a === void 0 ? void 0 : _a.neutralSecondary;
return new PlaceholderPlugin('', textColor
? {
textColor: textColor
}
: undefined);
}, [theme]);
useEffect(() => {
if (placeholderText !== undefined) {
placeholderPlugin.updatePlaceholder(placeholderText);
}
}, [placeholderPlugin, placeholderText]);
const toolbar = useMemo(() => {
return React.createElement(RichTextToolbar, { plugin: toolbarPlugin, strings: strings });
}, [strings, toolbarPlugin]);
const updatePlugin = useMemo(() => {
return new UpdateContentPlugin();
}, []);
const copyPastePlugin = useMemo(() => {
return new CopyPastePlugin();
}, []);
const onChangeContent = useCallback((/* @conditional-compile-remove(rich-text-editor-image-upload) */ shouldUpdateInlineImages) => {
if (editor.current === null) {
return;
}
const content = exportContent(editor.current);
/* @conditional-compile-remove(rich-text-editor-image-upload) */
let removedInlineImages = [];
/* @conditional-compile-remove(rich-text-editor-image-upload) */
if (shouldUpdateInlineImages) {
/* @conditional-compile-remove(rich-text-editor-image-upload) */
removedInlineImages = getRemovedInlineImages(content, previousInlineImages);
}
onChange &&
onChange(content, /* @conditional-compile-remove(rich-text-editor-image-upload) */ removedInlineImages);
/* @conditional-compile-remove(rich-text-editor-image-upload) */
setPreviousInlineImages(getPreviousInlineImages(content));
}, [onChange, /* @conditional-compile-remove(rich-text-editor-image-upload) */ previousInlineImages]);
useEffect(() => {
// don't set callback in plugin constructor to update callback without plugin recreation
updatePlugin.onUpdate = (event,
/* @conditional-compile-remove(rich-text-editor-image-upload) */ shouldRemoveInlineImages) => {
if (editor.current === null) {
return;
}
if (event === UpdateEvent.Blur || event === UpdateEvent.Dispose) {
onContentModelUpdate && onContentModelUpdate(editor.current.getContentModelCopy('disconnected'));
}
else {
const content = exportContent(editor.current);
/* @conditional-compile-remove(rich-text-editor-image-upload) */
let removedInlineImages = [];
/* @conditional-compile-remove(rich-text-editor-image-upload) */
if (shouldRemoveInlineImages) {
removedInlineImages = getRemovedInlineImages(content, previousInlineImages);
if (removedInlineImages.length > 0) {
removeLocalBlobs(inlineImageLocalBlobs, removedInlineImages);
}
}
onChange &&
onChange(content, /* @conditional-compile-remove(rich-text-editor-image-upload) */ removedInlineImages);
/* @conditional-compile-remove(rich-text-editor-image-upload) */
setPreviousInlineImages(getPreviousInlineImages(content));
}
};
}, [
onChange,
onContentModelUpdate,
updatePlugin,
/* @conditional-compile-remove(rich-text-editor-image-upload) */ previousInlineImages,
/* @conditional-compile-remove(rich-text-editor-image-upload) */ inlineImageLocalBlobs
]);
const undoRedoPlugin = useMemo(() => {
return new UndoRedoPlugin();
}, []);
/* @conditional-compile-remove(rich-text-editor-image-upload) */
useEffect(() => {
if (onInsertInlineImage) {
copyPastePlugin.onInsertInlineImage = (imageAttributes) => {
const { id, src } = imageAttributes;
setInlineImageLocalBlobs((prev) => {
if (!id || !src) {
return prev;
}
return Object.assign(Object.assign({}, prev), { [id]: src });
});
onInsertInlineImage(imageAttributes);
};
}
else {
copyPastePlugin.onInsertInlineImage = undefined;
}
undoRedoPlugin.onInsertInlineImage = onInsertInlineImage;
}, [copyPastePlugin, onInsertInlineImage, undoRedoPlugin]);
useEffect(() => {
undoRedoPlugin.onUpdateContent = () => {
onChangeContent(/* @conditional-compile-remove(rich-text-editor-image-upload) */ true);
};
}, [onChangeContent, undoRedoPlugin]);
const keyboardInputPlugin = useMemo(() => {
return new KeyboardInputPlugin();
}, []);
useEffect(() => {
// don't set callback in plugin constructor to update callback without plugin recreation
keyboardInputPlugin.onKeyDown = onKeyDown;
}, [keyboardInputPlugin, onKeyDown]);
useEffect(() => {
// don't set callback in plugin constructor to update callback without plugin recreation
keyboardInputPlugin.onCompositionUpdate = onCompositionUpdate;
}, [keyboardInputPlugin, onCompositionUpdate]);
const tableContextMenuPlugin = useMemo(() => {
return new TableEditContextMenuProvider();
}, []);
useEffect(() => {
tableContextMenuPlugin.updateStrings(strings);
}, [tableContextMenuPlugin, strings]);
const onContextMenuRender = useCallback((container, items, onDismiss) => {
setContextMenuProps({
items: items,
target: container,
onDismiss: onDismiss
});
}, []);
const onContextMenuDismiss = useCallback(() => {
setContextMenuProps(null);
}, []);
/* @conditional-compile-remove(rich-text-editor-image-upload) */
useEffect(() => {
copyPastePlugin.onPaste = onPaste;
}, [copyPastePlugin, onPaste]);
const plugins = useMemo(() => {
const contentEdit = new EditPlugin({ handleTabKey: false });
// AutoFormatPlugin previously was a part of the edit plugin
const autoFormatPlugin = new AutoFormatPlugin({ autoBullet: true, autoNumbering: true, autoLink: true });
const roosterPastePlugin = new PastePlugin(false, {
additionalDisallowedTags: ['head', '!doctype', '!cdata', '#comment'],
additionalAllowedTags: [],
styleSanitizers: DefaultSanitizers,
attributeSanitizers: {}
});
const shortcutPlugin = new ShortcutPlugin();
const contextMenuPlugin = new ContextMenuPlugin(onContextMenuRender, onContextMenuDismiss);
return [
placeholderPlugin,
keyboardInputPlugin,
contentEdit,
autoFormatPlugin,
updatePlugin,
copyPastePlugin,
roosterPastePlugin,
toolbarPlugin,
shortcutPlugin,
// contextPlugin and tableEditMenuProvider allow to show insert/delete menu for the table
contextMenuPlugin,
tableContextMenuPlugin,
undoRedoPlugin
];
}, [
onContextMenuRender,
onContextMenuDismiss,
placeholderPlugin,
keyboardInputPlugin,
updatePlugin,
copyPastePlugin,
toolbarPlugin,
tableContextMenuPlugin,
undoRedoPlugin
]);
const announcerStringGetter = useCallback((key) => {
var _a, _b;
switch (key) {
case 'announceListItemBullet':
return (_a = strings.richTextNewBulletedListItemAnnouncement) !== null && _a !== void 0 ? _a : '';
case 'announceListItemNumbering':
return (_b = strings.richTextNewNumberedListItemAnnouncement) !== null && _b !== void 0 ? _b : '';
case 'announceOnFocusLastCell':
return '';
}
}, [strings.richTextNewBulletedListItemAnnouncement, strings.richTextNewNumberedListItemAnnouncement]);
useEffect(() => {
var _a;
/* @conditional-compile-remove(rich-text-editor-image-upload) */
const prevInlineImage = getPreviousInlineImages(initialContent);
/* @conditional-compile-remove(rich-text-editor-image-upload) */
setPreviousInlineImages(prevInlineImage);
const initialModel = createEditorInitialModel(initialContent, contentModel);
if (editorDiv.current) {
editor.current = new Editor(editorDiv.current, {
generateColorKey: (color) => color, // Needed to ensure override of text color in dark mode.
inDarkMode: isDarkThemed(theme),
// doNotAdjustEditorColor is used to disable default color and background color for Rooster component
doNotAdjustEditorColor: true,
imageSelectionBorderColor: theme.palette.themePrimary,
tableCellSelectionBackgroundColor: theme.palette.neutralLight,
plugins: plugins,
initialModel: initialModel,
defaultModelToDomOptions: {
formatApplierOverride: {
// apply border and dataset formats for table
border: borderApplier,
dataset: dataSetApplier
}
},
announcerStringGetter: announcerStringGetter
});
}
if (autoFocus === 'sendBoxTextField') {
(_a = editor.current) === null || _a === void 0 ? void 0 : _a.focus();
}
return () => {
if (editor.current) {
editor.current.dispose();
editor.current = null;
}
};
// don't update the editor on deps update as everything is handled in separate hooks or plugins
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [theme, plugins, announcerStringGetter]);
useEffect(() => {
const themeDirectionValue = themeDirection(theme);
// check that editor exists and theme was actually changed
// as format.direction will be undefined if setDirection is not called
if (editor.current && previousThemeDirection.current !== themeDirectionValue) {
const format = getFormatState(editor.current);
if (format.direction !== themeDirectionValue) {
// should be set after the hook where editor is created as the editor might be null
// setDirection will cause the focus change back to the editor and this might not be what we want to do (autoFocus prop)
// that's why it's not part of the create editor hook
setDirection(editor.current, theme.rtl ? 'rtl' : 'ltr');
}
previousThemeDirection.current = themeDirectionValue;
}
}, [theme]);
return (React.createElement("div", { "data-testid": 'rich-text-editor-wrapper' },
showRichTextEditorFormatting && toolbar,
React.createElement("div", { className: richTextEditorWrapperStyle(theme) },
React.createElement("div", { id: "richTextSendBox", ref: editorDiv, tabIndex: 0, role: "textbox", "aria-multiline": "true", "data-testid": 'rooster-rich-text-editor', className: richTextEditorStyle(props.styles), "aria-label": placeholderText })),
contextMenuProps && React.createElement(ContextualMenu, Object.assign({}, contextMenuProps, { calloutProps: { isBeakVisible: false } }))));
});
const createEditorInitialModel = (initialContent, contentModel) => {
if (contentModel) {
// contentModel is the current content of the editor
return contentModel;
}
else {
const initialContentValue = initialContent;
const initialModel = initialContentValue && initialContentValue.length > 0 ? createModelFromHtml(initialContentValue) : undefined;
if (initialModel && initialModel.blocks.length > 0) {
// lastBlock should have blockType = paragraph, otherwise add a new paragraph
// to set focus to the end of the content
const lastBlock = initialModel.blocks[initialModel.blocks.length - 1];
if ((lastBlock === null || lastBlock === void 0 ? void 0 : lastBlock.blockType) === 'Paragraph') {
// now lastBlock is paragraph
setSelectionAfterLastSegment(initialModel, lastBlock);
}
else {
const block = createParagraph(false);
initialModel.blocks.push(block);
setSelectionAfterLastSegment(initialModel, block);
// add content to the paragraph, otherwise height might be calculated incorrectly
block.segments.push(createBr());
}
}
return initialModel;
}
};
const setSelectionAfterLastSegment = (model, block) => {
var _a;
//selection marker should have the same format as the last segment if any
const format = block.segments.length > 0 ? (_a = block.segments[block.segments.length - 1]) === null || _a === void 0 ? void 0 : _a.format : undefined;
const marker = createSelectionMarker(format);
block.segments.push(marker);
setSelection(model, marker);
};
const themeDirection = (theme) => {
return theme.rtl ? 'rtl' : 'ltr';
};
//# sourceMappingURL=RichTextEditor.js.map