From e82a23d364793e38edf8fbb0942968caeffe0261 Mon Sep 17 00:00:00 2001
From: Lei OT
Date: Tue, 24 Jun 2025 13:49:09 +0800
Subject: [PATCH] .
---
package.json | 2 +
src/components/RoosterEditor/MainPane.tsx | 564 ++++++++++++++++++
.../RoosterEditor/RoosterEditor.css | 25 +
.../RoosterEditor/SidePanePlugin.ts | 7 +
.../RoosterEditor/editorOptions/Code.tsx | 15 +
.../editorOptions/DefaultFormatPane.tsx | 146 +++++
.../editorOptions/EditorOptionsPlugin.ts | 88 +++
.../editorOptions/ExperimentalFeatures.tsx | 48 ++
.../editorOptions/OptionState.ts | 56 ++
.../editorOptions/OptionsPane.scss | 7 +
.../editorOptions/OptionsPane.tsx | 187 ++++++
.../RoosterEditor/editorOptions/Plugins.tsx | 323 ++++++++++
.../editorOptions/codes/AutoFormatCode.ts | 20 +
.../editorOptions/codes/ButtonsCode.ts | 26 +
.../editorOptions/codes/CodeElement.ts | 14 +
.../editorOptions/codes/DarkModeCode.ts | 7 +
.../editorOptions/codes/DefaultFormatCode.ts | 31 +
.../editorOptions/codes/EditorCode.ts | 40 ++
.../editorOptions/codes/HyperLinkCode.ts | 32 +
.../editorOptions/codes/MarkdownCode.ts | 17 +
.../editorOptions/codes/PluginsCode.ts | 50 ++
.../editorOptions/codes/SimplePluginCode.ts | 41 ++
.../editorOptions/codes/WatermarkCode.ts | 11 +
.../editorOptions/getReplacements.ts | 37 ++
src/components/RoosterEditor/index.jsx | 117 ++++
.../options/defaultDomToModelOption.ts | 8 +
.../options/demoUndeletableAnchorParser.ts | 13 +
.../plugins/FormatPainterPlugin.ts | 91 +++
.../plugins/SampleEntityPlugin.ts | 156 +++++
.../plugins/SamplePickerPlugin.tsx | 206 +++++++
.../plugins/UpdateContentPlugin.ts | 60 ++
.../plugins/formatpaintercursor.svg | 22 +
.../RoosterEditor/snapshot/SnapshotPane.scss | 61 ++
.../RoosterEditor/snapshot/SnapshotPane.tsx | 188 ++++++
.../RoosterEditor/snapshot/SnapshotPlugin.tsx | 85 +++
src/components/RoosterEditor/tabs/getTabs.ts | 61 ++
.../RoosterEditor/tabs/ribbonButtons.ts | 222 +++++++
src/components/RoosterEditor/theme/theme.scss | 27 +
src/components/RoosterEditor/theme/themes.ts | 59 ++
.../RoosterEditor/titleBar/TitleBar.scss | 58 ++
.../RoosterEditor/titleBar/TitleBar.tsx | 69 +++
.../titleBar/iconmonstr-github-1.svg | 1 +
.../RoosterEditor/utils/cssMonitor.ts | 54 ++
.../RoosterEditor/utils/trustedHTMLHandler.ts | 14 +
src/views/NewEmail.jsx | 69 ++-
45 files changed, 3412 insertions(+), 23 deletions(-)
create mode 100644 src/components/RoosterEditor/MainPane.tsx
create mode 100644 src/components/RoosterEditor/RoosterEditor.css
create mode 100644 src/components/RoosterEditor/SidePanePlugin.ts
create mode 100644 src/components/RoosterEditor/editorOptions/Code.tsx
create mode 100644 src/components/RoosterEditor/editorOptions/DefaultFormatPane.tsx
create mode 100644 src/components/RoosterEditor/editorOptions/EditorOptionsPlugin.ts
create mode 100644 src/components/RoosterEditor/editorOptions/ExperimentalFeatures.tsx
create mode 100644 src/components/RoosterEditor/editorOptions/OptionState.ts
create mode 100644 src/components/RoosterEditor/editorOptions/OptionsPane.scss
create mode 100644 src/components/RoosterEditor/editorOptions/OptionsPane.tsx
create mode 100644 src/components/RoosterEditor/editorOptions/Plugins.tsx
create mode 100644 src/components/RoosterEditor/editorOptions/codes/AutoFormatCode.ts
create mode 100644 src/components/RoosterEditor/editorOptions/codes/ButtonsCode.ts
create mode 100644 src/components/RoosterEditor/editorOptions/codes/CodeElement.ts
create mode 100644 src/components/RoosterEditor/editorOptions/codes/DarkModeCode.ts
create mode 100644 src/components/RoosterEditor/editorOptions/codes/DefaultFormatCode.ts
create mode 100644 src/components/RoosterEditor/editorOptions/codes/EditorCode.ts
create mode 100644 src/components/RoosterEditor/editorOptions/codes/HyperLinkCode.ts
create mode 100644 src/components/RoosterEditor/editorOptions/codes/MarkdownCode.ts
create mode 100644 src/components/RoosterEditor/editorOptions/codes/PluginsCode.ts
create mode 100644 src/components/RoosterEditor/editorOptions/codes/SimplePluginCode.ts
create mode 100644 src/components/RoosterEditor/editorOptions/codes/WatermarkCode.ts
create mode 100644 src/components/RoosterEditor/editorOptions/getReplacements.ts
create mode 100644 src/components/RoosterEditor/index.jsx
create mode 100644 src/components/RoosterEditor/options/defaultDomToModelOption.ts
create mode 100644 src/components/RoosterEditor/options/demoUndeletableAnchorParser.ts
create mode 100644 src/components/RoosterEditor/plugins/FormatPainterPlugin.ts
create mode 100644 src/components/RoosterEditor/plugins/SampleEntityPlugin.ts
create mode 100644 src/components/RoosterEditor/plugins/SamplePickerPlugin.tsx
create mode 100644 src/components/RoosterEditor/plugins/UpdateContentPlugin.ts
create mode 100644 src/components/RoosterEditor/plugins/formatpaintercursor.svg
create mode 100644 src/components/RoosterEditor/snapshot/SnapshotPane.scss
create mode 100644 src/components/RoosterEditor/snapshot/SnapshotPane.tsx
create mode 100644 src/components/RoosterEditor/snapshot/SnapshotPlugin.tsx
create mode 100644 src/components/RoosterEditor/tabs/getTabs.ts
create mode 100644 src/components/RoosterEditor/tabs/ribbonButtons.ts
create mode 100644 src/components/RoosterEditor/theme/theme.scss
create mode 100644 src/components/RoosterEditor/theme/themes.ts
create mode 100644 src/components/RoosterEditor/titleBar/TitleBar.scss
create mode 100644 src/components/RoosterEditor/titleBar/TitleBar.tsx
create mode 100644 src/components/RoosterEditor/titleBar/iconmonstr-github-1.svg
create mode 100644 src/components/RoosterEditor/utils/cssMonitor.ts
create mode 100644 src/components/RoosterEditor/utils/trustedHTMLHandler.ts
diff --git a/package.json b/package.json
index 08eaace..08c8fa9 100644
--- a/package.json
+++ b/package.json
@@ -22,6 +22,8 @@
"react-chat-elements": "^12.0.17",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.1",
+ "roosterjs": "^9.30.0",
+ "roosterjs-react": "^9.0.2",
"rxjs": "^7.8.1",
"uuid": "^9.0.1",
"zustand": "^4.5.7"
diff --git a/src/components/RoosterEditor/MainPane.tsx b/src/components/RoosterEditor/MainPane.tsx
new file mode 100644
index 0000000..fc902d8
--- /dev/null
+++ b/src/components/RoosterEditor/MainPane.tsx
@@ -0,0 +1,564 @@
+import * as React from 'react';
+import * as ReactDOM from 'react-dom';
+import SampleEntityPlugin from './plugins/SampleEntityPlugin';
+// import { ApiPlaygroundPlugin } from './sidePane/apiPlayground/ApiPlaygroundPlugin';
+// import { ContentModelPanePlugin } from './sidePane/contentModel/ContentModelPanePlugin';
+// import { darkModeButton } from './demoButtons/darkModeButton';
+import { defaultDomToModelOption } from './options/defaultDomToModelOption';
+import { Editor } from 'roosterjs-content-model-core';
+import { EditorOptionsPlugin } from './editorOptions/EditorOptionsPlugin';
+// import { EventViewPlugin } from './sidePane/eventViewer/EventViewPlugin';
+// import { exportContentButton } from './demoButtons/exportContentButton';
+import { FormatPainterPlugin } from './plugins/FormatPainterPlugin';
+// import { FormatStatePlugin } from './sidePane/formatState/FormatStatePlugin';
+import { getButtons } from './tabs/ribbonButtons';
+import { getDarkColor } from 'roosterjs-color-utils';
+// import { getPresetModelById } from './sidePane/presets/allPresets/allPresets';
+import { getTabs, tabNames } from './tabs/getTabs';
+import { getTheme } from './theme/themes';
+// import { MarkdownPanePlugin } from './sidePane/MarkdownPane/MarkdownPanePlugin';
+import { OptionState, UrlPlaceholder } from './editorOptions/OptionState';
+// import { popoutButton } from './demoButtons/popoutButton';
+// import { PresetPlugin } from './sidePane/presets/PresetPlugin';
+import { registerWindowForCss, unregisterWindowForCss } from './utils/cssMonitor';
+import { SamplePickerPlugin } from './plugins/SamplePickerPlugin';
+// // // import { SidePane } from './sidePane/SidePane';
+// // // import { SidePanePlugin } from './sidePane/SidePanePlugin';
+import { SnapshotPlugin } from './snapshot/SnapshotPlugin';
+import { ThemeProvider } from '@fluentui/react/lib/Theme';
+// import { TitleBar } from './titleBar/TitleBar';
+import { trustedHTMLHandler } from './utils/trustedHTMLHandler';
+import { undeletableLinkChecker } from './options/demoUndeletableAnchorParser';
+import { UpdateContentPlugin } from './plugins/UpdateContentPlugin';
+import { WindowProvider } from '@fluentui/react/lib/WindowProvider';
+// import { zoomButton } from './demoButtons/zoomButton';
+import type { RibbonButton, RibbonPlugin } from 'roosterjs-react';
+import {
+ createContextMenuPlugin,
+ createEmojiPlugin,
+ createImageEditMenuProvider,
+ createListEditMenuProvider,
+ createPasteOptionPlugin,
+ createRibbonPlugin,
+ createTableEditMenuProvider,
+ redoButton,
+ Rooster,
+ undoButton,
+ Ribbon,
+} from 'roosterjs-react';
+import {
+ Border,
+ Colors,
+ ContentModelDocument,
+ EditorOptions,
+ EditorPlugin,
+ IEditor,
+ KnownAnnounceStrings,
+ Snapshots,
+} from 'roosterjs-content-model-types';
+import {
+ AutoFormatPlugin,
+ CustomReplacePlugin,
+ EditPlugin,
+ HiddenPropertyPlugin,
+ HyperlinkPlugin,
+ ImageEditPlugin,
+ MarkdownPlugin,
+ PastePlugin,
+ ShortcutPlugin,
+ TableEditPlugin,
+ WatermarkPlugin,
+} from 'roosterjs-content-model-plugins';
+// import DOMPurify = require('dompurify');
+
+// const styles = require('./MainPane.scss');
+
+export interface MainPaneState {
+ showSidePane: boolean;
+ popoutWindow: Window;
+ initState: OptionState;
+ scale: number;
+ isDarkMode: boolean;
+ isRtl: boolean;
+ activeTab: tabNames;
+ tableBorderFormat?: Border;
+ editorCreator: (div: HTMLDivElement, options: EditorOptions) => IEditor;
+}
+
+// const PopoutRoot = 'mainPane';
+// const POPOUT_HTML = `RoosterJs Demo Site`;
+// const POPOUT_FEATURES = 'menubar=no,statusbar=no,width=1200,height=800';
+// const POPOUT_URL = 'about:blank';
+// const POPOUT_TARGET = '_blank';
+
+export class MainPane extends React.Component<{}, MainPaneState> {
+ private mouseX: number;
+ private static instance: MainPane;
+ private popoutRoot: HTMLElement;
+ // private formatStatePlugin: FormatStatePlugin;
+ private editorOptionPlugin: EditorOptionsPlugin;
+ // private eventViewPlugin: EventViewPlugin;
+ // private apiPlaygroundPlugin: ApiPlaygroundPlugin;
+ // private contentModelPanePlugin: ContentModelPanePlugin;
+ // private presetPlugin: PresetPlugin;
+ private ribbonPlugin: RibbonPlugin;
+ private snapshotPlugin: SnapshotPlugin;
+ private formatPainterPlugin: FormatPainterPlugin;
+ private samplePickerPlugin: SamplePickerPlugin;
+ private snapshots: Snapshots;
+ private imageEditPlugin: ImageEditPlugin;
+ // private markdownPanePlugin: MarkdownPanePlugin;
+
+ // protected sidePane = React.createRef();
+ protected updateContentPlugin: UpdateContentPlugin;
+ protected model: ContentModelDocument | null = null;
+ private knownColors: Record = {};
+ protected themeMatch = window.matchMedia?.('(prefers-color-scheme: dark)');
+
+ static getInstance() {
+ return this.instance;
+ }
+
+ static readonly editorDivId = 'RoosterJsContentDiv';
+
+ constructor(props: {}) {
+ super(props);
+
+ MainPane.instance = this;
+ this.updateContentPlugin = new UpdateContentPlugin(this.onUpdate);
+
+ this.snapshots = {
+ snapshots: [],
+ totalSize: 0,
+ currentIndex: -1,
+ autoCompleteIndex: -1,
+ maxSize: 1e7,
+ };
+
+ // this.formatStatePlugin = new FormatStatePlugin();
+ this.editorOptionPlugin = new EditorOptionsPlugin();
+ // this.eventViewPlugin = new EventViewPlugin();
+ // this.apiPlaygroundPlugin = new ApiPlaygroundPlugin();
+ this.snapshotPlugin = new SnapshotPlugin(this.snapshots);
+ // this.contentModelPanePlugin = new ContentModelPanePlugin();
+ // this.presetPlugin = new PresetPlugin();
+ this.ribbonPlugin = createRibbonPlugin();
+ this.formatPainterPlugin = new FormatPainterPlugin();
+ this.samplePickerPlugin = new SamplePickerPlugin();
+ this.imageEditPlugin = new ImageEditPlugin();
+ // this.markdownPanePlugin = new MarkdownPanePlugin();
+
+ this.state = {
+ showSidePane: window.location.hash != '',
+ popoutWindow: null,
+ initState: this.editorOptionPlugin.getBuildInPluginState(),
+ scale: 1,
+ isDarkMode: this.themeMatch?.matches || false,
+ editorCreator: null,
+ isRtl: false,
+ tableBorderFormat: {
+ width: '1px',
+ style: 'solid',
+ color: '#ABABAB',
+ },
+ activeTab: 'all',
+ };
+ }
+
+ render() {
+ const theme = getTheme(this.state.isDarkMode);
+ return (
+
+ {/* {this.renderTitleBar()} */}
+ {!this.state.popoutWindow && this.renderTabs()}
+ {!this.state.popoutWindow && this.renderRibbon()}
+
+ {this.state.popoutWindow ? this.renderPopout() : this.renderEditor()}
+
+
+ );
+ }
+
+ componentDidMount() {
+ this.themeMatch?.addEventListener('change', this.onThemeChange);
+ this.resetEditor();
+ }
+
+ componentWillUnmount() {
+ this.themeMatch?.removeEventListener('change', this.onThemeChange);
+ }
+
+ // popout() {
+ // this.updateContentPlugin.update();
+
+ // const win = window.open(POPOUT_URL, POPOUT_TARGET, POPOUT_FEATURES);
+ // win.document.write(
+ // (DOMPurify.sanitize(POPOUT_HTML, {
+ // ADD_TAGS: ['head', 'meta', 'iframe'],
+ // ADD_ATTR: ['name', 'content'],
+ // WHOLE_DOCUMENT: true,
+ // ALLOW_UNKNOWN_PROTOCOLS: true,
+ // RETURN_TRUSTED_TYPE: true,
+ // }) as any) as string
+ // );
+ // win.addEventListener('beforeunload', () => {
+ // this.updateContentPlugin.update();
+
+ // unregisterWindowForCss(win);
+ // this.setState({ popoutWindow: null });
+ // });
+
+ // registerWindowForCss(win);
+
+ // this.popoutRoot = win.document.getElementById(PopoutRoot);
+ // this.setState({
+ // popoutWindow: win,
+ // });
+ // }
+
+ resetEditorPlugin(pluginState: OptionState) {
+ this.updateContentPlugin.update();
+ this.setState({
+ initState: pluginState,
+ });
+ this.resetEditor();
+ }
+
+ setScale(scale: number): void {
+ this.setState({
+ scale: scale,
+ });
+ }
+
+ getTableBorder(): Border {
+ return this.state.tableBorderFormat;
+ }
+
+ setTableBorderColor(color: string): void {
+ this.setState({
+ tableBorderFormat: { ...this.getTableBorder(), color },
+ });
+ }
+
+ setTableBorderWidth(width: string): void {
+ this.setState({
+ tableBorderFormat: { ...this.getTableBorder(), width },
+ });
+ }
+
+ setTableBorderStyle(style: string): void {
+ this.setState({
+ tableBorderFormat: { ...this.getTableBorder(), style },
+ });
+ }
+
+ toggleDarkMode(): void {
+ this.setState({
+ isDarkMode: !this.state.isDarkMode,
+ });
+ }
+
+ changeRibbon(id: tabNames): void {
+ this.setState({
+ activeTab: id,
+ });
+ }
+
+ setPreset(preset: ContentModelDocument) {
+ this.model = preset;
+ }
+
+ setPageDirection(isRtl: boolean): void {
+ this.setState({ isRtl: isRtl });
+ [window, this.state.popoutWindow].forEach(win => {
+ if (win) {
+ win.document.body.dir = isRtl ? 'rtl' : 'ltr';
+ }
+ });
+ }
+
+ // private renderTitleBar() {
+ // return ;
+ // }
+
+ private renderTabs() {
+ const tabs = getTabs();
+ const topRightButtons: RibbonButton[] = [
+ undoButton,
+ redoButton,
+ // zoomButton,
+ // darkModeButton,
+ // exportContentButton,
+ ];
+ // this.state.popoutWindow ? null : topRightButtons.push(popoutButton);
+
+ return (
+
+
+
+
+ );
+ }
+ private renderRibbon() {
+ return (
+
+ );
+ }
+
+ // private renderSidePane(fullWidth: boolean) {
+ // return (
+ //
+ // );
+ // }
+
+ private resetEditor() {
+ this.setState({
+ editorCreator: (div: HTMLDivElement, options: EditorOptions) => {
+ return new Editor(div, options);
+ },
+ });
+ }
+
+ private renderEditor() {
+ // Set preset if found
+ const search = new URLSearchParams(document.location.search);
+ const hasPreset = search.get('preset');
+ if (hasPreset) {
+ // this.setPreset(getPresetModelById(hasPreset)); // todo:
+ }
+
+ const editorStyles = {
+ transform: `scale(${this.state.scale})`,
+ transformOrigin: this.state.isRtl ? 'right top' : 'left top',
+ height: `calc(${100 / this.state.scale}%)`,
+ width: `calc(${100 / this.state.scale}%)`,
+ };
+ const plugins: EditorPlugin[] = [
+ this.ribbonPlugin,
+ this.formatPainterPlugin,
+ this.samplePickerPlugin,
+ ...this.getToggleablePlugins(),
+ // this.contentModelPanePlugin.getInnerRibbonPlugin(),
+ this.updateContentPlugin,
+ ];
+
+ // if (this.state.showSidePane || this.state.popoutWindow) {
+ // plugins.push(...this.getSidePanePlugins());
+ // }
+
+ this.updateContentPlugin.update();
+
+ return (
+
+
+ {this.state.editorCreator && (
+
+ )}
+
+
+ );
+ }
+
+ private renderMainPane() {
+ return (
+ <>
+ {this.renderEditor()}
+ {this.state.showSidePane ? (
+ <>
+
+ {/* {this.renderSidePane(false)} */}
+ {/* {this.renderSidePaneButton()} */}
+ >
+ ) : (
+ // this.renderSidePaneButton()
+ null
+ )}
+ >
+ );
+ }
+
+ // private renderSidePaneButton() {
+ // return (
+ //
+ // );
+ // }
+
+ private renderPopout() {
+ return (
+ <>
+ {/* {this.renderSidePane(true )} */}
+ {ReactDOM.createPortal(
+
+
+
+ {this.renderTabs()}
+ {this.renderRibbon()}
+
{this.renderEditor()}
+
+
+ ,
+ this.popoutRoot
+ )}
+ >
+ );
+ }
+
+ private onMouseDown = (e: React.MouseEvent) => {
+ document.addEventListener('mousemove', this.onMouseMove, true);
+ document.addEventListener('mouseup', this.onMouseUp, true);
+ document.body.style.userSelect = 'none';
+ this.mouseX = e.pageX;
+ };
+
+ private onMouseMove = (e: MouseEvent) => {
+ // this.sidePane.current.changeWidth(this.mouseX - e.pageX);
+ this.mouseX = e.pageX;
+ };
+
+ private onMouseUp = (e: MouseEvent) => {
+ document.removeEventListener('mousemove', this.onMouseMove, true);
+ document.removeEventListener('mouseup', this.onMouseUp, true);
+ document.body.style.userSelect = '';
+ };
+
+ private onUpdate = (model: ContentModelDocument) => {
+ this.model = model;
+ };
+
+ private onShowSidePane = () => {
+ this.setState({
+ showSidePane: true,
+ });
+ this.resetEditor();
+ };
+
+ private onHideSidePane = () => {
+ this.setState({
+ showSidePane: false,
+ });
+ this.resetEditor();
+ window.location.hash = '';
+ };
+
+ private onThemeChange = () => {
+ this.setState({
+ isDarkMode: this.themeMatch?.matches || false,
+ });
+ };
+
+ // private getSidePanePlugins(): SidePanePlugin[] {
+ // return [
+ // this.formatStatePlugin,
+ // this.editorOptionPlugin,
+ // this.eventViewPlugin,
+ // this.apiPlaygroundPlugin,
+ // this.snapshotPlugin,
+ // this.contentModelPanePlugin,
+ // this.presetPlugin,
+ // this.markdownPanePlugin,
+ // ];
+ // }
+
+ private getToggleablePlugins(): EditorPlugin[] {
+ const {
+ pluginList,
+ allowExcelNoBorderTable,
+ listMenu,
+ tableMenu,
+ imageMenu,
+ watermarkText,
+ markdownOptions,
+ autoFormatOptions,
+ linkTitle,
+ customReplacements,
+ editPluginOptions,
+ } = this.state.initState;
+ return [
+ pluginList.autoFormat && new AutoFormatPlugin(autoFormatOptions),
+ pluginList.edit && new EditPlugin(editPluginOptions),
+ pluginList.paste && new PastePlugin(allowExcelNoBorderTable),
+ pluginList.shortcut && new ShortcutPlugin(),
+ pluginList.tableEdit && new TableEditPlugin(),
+ pluginList.watermark && new WatermarkPlugin(watermarkText),
+ pluginList.markdown && new MarkdownPlugin(markdownOptions),
+ pluginList.imageEditPlugin && this.imageEditPlugin,
+ pluginList.emoji && createEmojiPlugin(),
+ pluginList.pasteOption && createPasteOptionPlugin(),
+ pluginList.sampleEntity && new SampleEntityPlugin(),
+ pluginList.contextMenu && createContextMenuPlugin(),
+ pluginList.contextMenu && listMenu && createListEditMenuProvider(),
+ pluginList.contextMenu && tableMenu && createTableEditMenuProvider(),
+ pluginList.contextMenu &&
+ imageMenu &&
+ createImageEditMenuProvider(this.imageEditPlugin),
+ pluginList.hyperlink &&
+ new HyperlinkPlugin(
+ linkTitle?.indexOf(UrlPlaceholder) >= 0
+ ? url => linkTitle.replace(UrlPlaceholder, url)
+ : linkTitle
+ ),
+ pluginList.customReplace && new CustomReplacePlugin(customReplacements),
+ pluginList.hiddenProperty &&
+ new HiddenPropertyPlugin({
+ undeletableLinkChecker: undeletableLinkChecker,
+ }),
+ ].filter(x => !!x);
+ }
+}
+
+const AnnounceStringMap: Record = {
+ announceListItemBullet: 'Auto corrected Bullet',
+ announceListItemNumbering: 'Auto corrected {0}',
+ announceOnFocusLastCell: 'Warning, pressing tab here adds an extra row.',
+};
+
+function getAnnouncingString(key: KnownAnnounceStrings) {
+ return AnnounceStringMap[key];
+}
+
+export function mount(parent: HTMLElement) {
+ ReactDOM.render(, parent);
+}
diff --git a/src/components/RoosterEditor/RoosterEditor.css b/src/components/RoosterEditor/RoosterEditor.css
new file mode 100644
index 0000000..34cca91
--- /dev/null
+++ b/src/components/RoosterEditor/RoosterEditor.css
@@ -0,0 +1,25 @@
+
+/* Basic styling for the editor container */
+.rooster-editor-container {
+ border: 1px solid #d9d9d9; /* Ant Design border color */
+ border-radius: 4px; /* Ant Design border radius */
+ min-height: 200px;
+ padding: 8px; /* Ant Design default padding for inputs */
+ box-sizing: border-box; /* Ensures padding is included in width */
+}
+
+/* Style the actual editor div (RoosterJs injects content here) */
+.rooster-editor {
+ min-height: 180px; /* Adjust as needed */
+ outline: none; /* Remove default focus outline */
+}
+
+/* Optional: Basic styling for the content within the editor */
+.rooster-editor p {
+ margin-bottom: 8px; /* Space out paragraphs */
+}
+
+.rooster-editor ul,
+.rooster-editor ol {
+ padding-left: 20px; /* Indent lists */
+}
diff --git a/src/components/RoosterEditor/SidePanePlugin.ts b/src/components/RoosterEditor/SidePanePlugin.ts
new file mode 100644
index 0000000..f15a4fd
--- /dev/null
+++ b/src/components/RoosterEditor/SidePanePlugin.ts
@@ -0,0 +1,7 @@
+import type { EditorPlugin } from 'roosterjs-content-model-types';
+
+export interface SidePanePlugin extends EditorPlugin {
+ getTitle: () => string;
+ renderSidePane: (updateHash: (pluginName?: string, path?: string[]) => void) => JSX.Element;
+ setHashPath?: (path: string[]) => void;
+}
diff --git a/src/components/RoosterEditor/editorOptions/Code.tsx b/src/components/RoosterEditor/editorOptions/Code.tsx
new file mode 100644
index 0000000..fb99058
--- /dev/null
+++ b/src/components/RoosterEditor/editorOptions/Code.tsx
@@ -0,0 +1,15 @@
+import * as React from 'react';
+
+export interface CodeProps {
+ code: string;
+}
+
+export class Code extends React.Component {
+ render() {
+ return (
+
+ );
+ }
+}
diff --git a/src/components/RoosterEditor/editorOptions/DefaultFormatPane.tsx b/src/components/RoosterEditor/editorOptions/DefaultFormatPane.tsx
new file mode 100644
index 0000000..302d448
--- /dev/null
+++ b/src/components/RoosterEditor/editorOptions/DefaultFormatPane.tsx
@@ -0,0 +1,146 @@
+import * as React from 'react';
+import { ContentModelSegmentFormat } from 'roosterjs-content-model-types';
+import { getObjectKeys } from 'roosterjs-content-model-dom';
+import { OptionState } from './OptionState';
+
+type ToggleFormatId = 'fontWeight' | 'italic' | 'underline';
+type SelectFormatId = 'textColor' | 'backgroundColor' | 'fontFamily' | 'fontSize';
+
+const styles = require('./OptionsPane.scss');
+const NOT_SET = 'NotSet';
+
+export interface DefaultFormatProps {
+ state: ContentModelSegmentFormat;
+ resetState: (callback: (state: OptionState) => void, resetEditor: boolean) => void;
+}
+
+export class DefaultFormatPane extends React.Component {
+ render() {
+ return (
+ <>
+
+
+ {this.renderFormatItem('fontWeight', 'Bold')}
+ {this.renderFormatItem('italic', 'Italic')}
+ {this.renderFormatItem('underline', 'Underline')}
+
+
+
+
+ {this.renderSelectItem('fontFamily', 'Font family: ', {
+ [NOT_SET]: 'Not Set',
+ Arial: 'Arial',
+ Calibri: 'Calibri',
+ 'Courier New': 'Courier New',
+ Tahoma: 'Tahoma',
+ 'Times New Roman': 'Times New Roman',
+ })}
+ {this.renderSelectItem('fontSize', 'Font size: ', {
+ [NOT_SET]: 'Not Set',
+ '8pt': '8',
+ '10pt': '10',
+ '11pt': '11',
+ '12pt': '12',
+ '16pt': '16',
+ '20pt': '20',
+ '36pt': '36',
+ '72pt': '72',
+ })}
+ {this.renderSelectItem('textColor', 'Text color: ', {
+ [NOT_SET]: 'Not Set',
+ '#757b80': 'Gray',
+ '#bd1398': 'Violet',
+ '#7232ad': 'Purple',
+ '#006fc9': 'Blue',
+ '#4ba524': 'Green',
+ '#e2c501': 'Yellow',
+ '#d05c12': 'Orange',
+ '#ff0000': 'Red',
+ '#ffffff': 'White',
+ '#000000': 'Black',
+ })}
+ {this.renderSelectItem('backgroundColor', 'Back color: ', {
+ [NOT_SET]: 'Not Set',
+ '#ffff00': 'Yellow',
+ '#00ff00': 'Green',
+ '#00ffff': 'Cyan',
+ '#ff00ff': 'Purple',
+ '#0000ff': 'Blue',
+ '#ff0000': 'Red',
+ '#bebebe': 'Gray',
+ '#666666': 'Dark Gray',
+ '#ffffff': 'White',
+ '#000000': 'Black',
+ })}
+
+
+ >
+ );
+ }
+
+ private renderFormatItem(id: ToggleFormatId, text: string): JSX.Element {
+ let checked = !!this.props.state[id];
+
+ return (
+
+
+ this.onFormatClick(id)}
+ />
+ |
+
+
+
+
+ |
+
+ );
+ }
+
+ private renderSelectItem(
+ id: SelectFormatId,
+ label: string,
+ items: { [key: string]: string }
+ ): JSX.Element {
+ return (
+
+ {label} |
+
+
+ |
+
+ );
+ }
+
+ private onFormatClick = (id: ToggleFormatId) => {
+ this.props.resetState(state => {
+ let checkbox = document.getElementById(id) as HTMLInputElement;
+
+ if (id == 'fontWeight') {
+ state.defaultFormat.fontWeight = checkbox.checked ? 'bold' : undefined;
+ } else {
+ state.defaultFormat[id] = checkbox.checked;
+ }
+ }, true);
+ };
+
+ private onSelectChanged = (id: SelectFormatId) => {
+ this.props.resetState(state => {
+ let value = (document.getElementById(id) as HTMLSelectElement).value;
+
+ state.defaultFormat[id] = value == NOT_SET ? undefined : value;
+ }, true);
+ };
+}
diff --git a/src/components/RoosterEditor/editorOptions/EditorOptionsPlugin.ts b/src/components/RoosterEditor/editorOptions/EditorOptionsPlugin.ts
new file mode 100644
index 0000000..1312a41
--- /dev/null
+++ b/src/components/RoosterEditor/editorOptions/EditorOptionsPlugin.ts
@@ -0,0 +1,88 @@
+import { emojiReplacements } from './getReplacements';
+import { ExperimentalFeature } from 'roosterjs-content-model-types';
+import { OptionPaneProps, OptionState, UrlPlaceholder } from './OptionState';
+import { OptionsPane } from './OptionsPane';
+import { SidePaneElementProps } from '../SidePaneElement';
+import { SidePanePluginImpl } from '../SidePanePluginImpl';
+
+const initialState: OptionState = {
+ pluginList: {
+ autoFormat: true,
+ edit: true,
+ paste: true,
+ shortcut: true,
+ tableEdit: true,
+ contextMenu: true,
+ watermark: true,
+ emoji: true,
+ pasteOption: true,
+ sampleEntity: true,
+ markdown: true,
+ imageEditPlugin: true,
+ hyperlink: true,
+ customReplace: true,
+ hiddenProperty: true,
+ },
+ defaultFormat: {
+ fontFamily: 'Calibri',
+ fontSize: '11pt',
+ textColor: '#000000',
+ },
+ linkTitle: 'Ctrl+Click to follow the link:' + UrlPlaceholder,
+ watermarkText: 'Type content here ...',
+ forcePreserveRatio: false,
+ isRtl: false,
+ disableCache: false,
+ tableFeaturesContainerSelector: '#' + 'EditorContainer',
+ allowExcelNoBorderTable: false,
+ imageMenu: true,
+ tableMenu: true,
+ listMenu: true,
+ autoFormatOptions: {
+ autoBullet: true,
+ autoLink: true,
+ autoNumbering: true,
+ autoUnlink: false,
+ autoHyphen: true,
+ autoFraction: true,
+ autoOrdinals: true,
+ autoMailto: true,
+ autoTel: true,
+ removeListMargins: false,
+ autoHorizontalLine: true,
+ },
+ markdownOptions: {
+ bold: true,
+ italic: true,
+ strikethrough: true,
+ codeFormat: {},
+ },
+ editPluginOptions: {
+ handleTabKey: true,
+ },
+ customReplacements: emojiReplacements,
+ experimentalFeatures: new Set([
+ 'PersistCache',
+ 'HandleEnterKey',
+ 'CustomCopyCut',
+ ]),
+};
+
+export class EditorOptionsPlugin extends SidePanePluginImpl {
+ constructor() {
+ super(OptionsPane, 'options', 'Editor Options');
+ }
+
+ getBuildInPluginState(): OptionState {
+ let result: OptionState;
+ this.getComponent(component => (result = component.getState()));
+ return result || initialState;
+ }
+
+ getComponentProps(base: SidePaneElementProps) {
+ return {
+ ...initialState,
+ ...base,
+ };
+ }
+}
diff --git a/src/components/RoosterEditor/editorOptions/ExperimentalFeatures.tsx b/src/components/RoosterEditor/editorOptions/ExperimentalFeatures.tsx
new file mode 100644
index 0000000..32f18ed
--- /dev/null
+++ b/src/components/RoosterEditor/editorOptions/ExperimentalFeatures.tsx
@@ -0,0 +1,48 @@
+import * as React from 'react';
+import { ExperimentalFeature } from 'roosterjs-content-model-types';
+import { OptionState } from './OptionState';
+
+export interface DefaultFormatProps {
+ state: OptionState;
+ resetState: (callback: (state: OptionState) => void, resetEditor: boolean) => void;
+}
+
+export class ExperimentalFeatures extends React.Component {
+ render() {
+ return (
+ <>
+ {this.renderFeature('PersistCache')}
+ {this.renderFeature('HandleEnterKey')}
+ {this.renderFeature('CustomCopyCut')}
+ >
+ );
+ }
+
+ private renderFeature(featureName: ExperimentalFeature): JSX.Element {
+ let checked = this.props.state.experimentalFeatures.has(featureName);
+
+ return (
+
+ this.onFeatureClick(featureName)}
+ />
+
+
+ );
+ }
+
+ private onFeatureClick = (featureName: ExperimentalFeature) => {
+ this.props.resetState(state => {
+ let checkbox = document.getElementById(featureName) as HTMLInputElement;
+
+ if (checkbox.checked) {
+ state.experimentalFeatures.add(featureName);
+ } else {
+ state.experimentalFeatures.delete(featureName);
+ }
+ }, true);
+ };
+}
diff --git a/src/components/RoosterEditor/editorOptions/OptionState.ts b/src/components/RoosterEditor/editorOptions/OptionState.ts
new file mode 100644
index 0000000..bb37560
--- /dev/null
+++ b/src/components/RoosterEditor/editorOptions/OptionState.ts
@@ -0,0 +1,56 @@
+import {
+ AutoFormatOptions,
+ CustomReplace,
+ EditOptions,
+ MarkdownOptions,
+} from 'roosterjs-content-model-plugins';
+import type { SidePaneElementProps } from '../SidePaneElement';
+import type { ContentModelSegmentFormat, ExperimentalFeature } from 'roosterjs-content-model-types';
+
+export interface BuildInPluginList {
+ autoFormat: boolean;
+ edit: boolean;
+ paste: boolean;
+ shortcut: boolean;
+ tableEdit: boolean;
+ contextMenu: boolean;
+ watermark: boolean;
+ emoji: boolean;
+ pasteOption: boolean;
+ sampleEntity: boolean;
+ markdown: boolean;
+ hyperlink: boolean;
+ imageEditPlugin: boolean;
+ customReplace: boolean;
+ hiddenProperty: boolean;
+}
+
+export interface OptionState {
+ pluginList: BuildInPluginList;
+
+ // New plugin options
+ allowExcelNoBorderTable: boolean;
+ listMenu: boolean;
+ tableMenu: boolean;
+ imageMenu: boolean;
+ watermarkText: string;
+ autoFormatOptions: AutoFormatOptions;
+ markdownOptions: MarkdownOptions;
+ customReplacements: CustomReplace[];
+ editPluginOptions: EditOptions;
+
+ // Legacy plugin options
+ defaultFormat: ContentModelSegmentFormat;
+ linkTitle: string;
+ forcePreserveRatio: boolean;
+ tableFeaturesContainerSelector: string;
+
+ // Editor options
+ isRtl: boolean;
+ disableCache: boolean;
+ experimentalFeatures: Set;
+}
+
+export interface OptionPaneProps extends OptionState, SidePaneElementProps {}
+
+export const UrlPlaceholder = '$url$';
diff --git a/src/components/RoosterEditor/editorOptions/OptionsPane.scss b/src/components/RoosterEditor/editorOptions/OptionsPane.scss
new file mode 100644
index 0000000..8cb7419
--- /dev/null
+++ b/src/components/RoosterEditor/editorOptions/OptionsPane.scss
@@ -0,0 +1,7 @@
+.checkboxColumn {
+ vertical-align: top;
+}
+
+.defaultFormatLabel {
+ white-space: nowrap;
+}
diff --git a/src/components/RoosterEditor/editorOptions/OptionsPane.tsx b/src/components/RoosterEditor/editorOptions/OptionsPane.tsx
new file mode 100644
index 0000000..951aa42
--- /dev/null
+++ b/src/components/RoosterEditor/editorOptions/OptionsPane.tsx
@@ -0,0 +1,187 @@
+import * as React from 'react';
+import { Code } from './Code';
+import { DefaultFormatPane } from './DefaultFormatPane';
+import { EditorCode } from './codes/EditorCode';
+import { ExperimentalFeatures } from './ExperimentalFeatures';
+import { MainPane } from '../../mainPane/MainPane';
+import { OptionPaneProps, OptionState } from './OptionState';
+import { Plugins } from './Plugins';
+
+const htmlStart =
+ '\n' +
+ '\n' +
+ '\n';
+const htmlButtons =
+ '\n' +
+ '\n' +
+ '\n' +
+ '\n' +
+ '\n' +
+ '\n' +
+ '\n' +
+ '\n' +
+ '\n';
+'\n';
+const jsCode = '\n';
+const htmlEnd = '\n' + '';
+
+export class OptionsPane extends React.Component {
+ private exportForm = React.createRef();
+ private exportData = React.createRef();
+ private rtl = React.createRef();
+ private disableCache = React.createRef();
+
+ constructor(props: OptionPaneProps) {
+ super(props);
+ this.state = { ...props };
+ }
+ render() {
+ const editorCode = new EditorCode(this.state);
+ const html = this.getHtml();
+
+ return (
+
+
+
+ Default Format
+
+
+
+
+
+ Plugins
+
+
+
+
+
+ Experimental features
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ HTML Code:
+
+
+
+
+
+ Typescript Code:
+
+
+
+
+
+ );
+ }
+
+ getState(): OptionState {
+ return { ...this.state };
+ }
+
+ private resetState = (callback: (state: OptionState) => void, resetEditor: boolean) => {
+ let state: OptionState = {
+ linkTitle: this.state.linkTitle,
+ watermarkText: this.state.watermarkText,
+ pluginList: { ...this.state.pluginList },
+ defaultFormat: { ...this.state.defaultFormat },
+ forcePreserveRatio: this.state.forcePreserveRatio,
+
+ isRtl: this.state.isRtl,
+ disableCache: this.state.disableCache,
+ tableFeaturesContainerSelector: this.state.tableFeaturesContainerSelector,
+ allowExcelNoBorderTable: this.state.allowExcelNoBorderTable,
+ listMenu: this.state.listMenu,
+ tableMenu: this.state.tableMenu,
+ imageMenu: this.state.imageMenu,
+ autoFormatOptions: { ...this.state.autoFormatOptions },
+ markdownOptions: { ...this.state.markdownOptions },
+ customReplacements: this.state.customReplacements,
+ experimentalFeatures: this.state.experimentalFeatures,
+ editPluginOptions: { ...this.state.editPluginOptions },
+ };
+
+ if (callback) {
+ callback(state);
+ this.setState(state);
+ }
+
+ if (resetEditor) {
+ MainPane.getInstance().resetEditorPlugin(state);
+ }
+ };
+
+ private onExportRoosterContentModel = () => {
+ let editor = new EditorCode(this.state);
+ let code = editor.getCode();
+ let json = {
+ title: 'RoosterJs',
+ html: this.getHtml(),
+ head: '',
+ js: code,
+ js_pre_processor: 'typescript',
+ };
+ this.exportData.current.value = JSON.stringify(json);
+ this.exportForm.current.submit();
+ };
+
+ private onToggleDirection = () => {
+ let isRtl = this.rtl.current.checked;
+ this.setState({
+ isRtl: isRtl,
+ });
+ MainPane.getInstance().setPageDirection(isRtl);
+ };
+
+ private onToggleCacheModel = () => {
+ this.resetState(state => {
+ state.disableCache = this.disableCache.current.checked;
+ }, true);
+ };
+
+ private getHtml() {
+ return `${htmlStart}${htmlButtons}${jsCode}${htmlEnd}`;
+ }
+}
diff --git a/src/components/RoosterEditor/editorOptions/Plugins.tsx b/src/components/RoosterEditor/editorOptions/Plugins.tsx
new file mode 100644
index 0000000..986eb3f
--- /dev/null
+++ b/src/components/RoosterEditor/editorOptions/Plugins.tsx
@@ -0,0 +1,323 @@
+import * as React from 'react';
+import { UrlPlaceholder } from './OptionState';
+import type { BuildInPluginList, OptionState } from './OptionState';
+
+const styles = require('./OptionsPane.scss');
+
+export interface PluginsProps {
+ state: OptionState;
+ resetState: (callback: (state: OptionState) => void, resetEditor: boolean) => void;
+}
+
+abstract class PluginsBase extends React.Component<
+ PluginsProps,
+ {}
+> {
+ abstract render(): JSX.Element;
+
+ protected renderPluginItem(
+ id: PluginKey,
+ text: string,
+ moreOptions?: JSX.Element
+ ): JSX.Element {
+ const checked = this.props.state.pluginList[id];
+
+ return (
+
+
+ this.onPluginClick(id)}
+ />
+ |
+
+
+
+
+ {checked && moreOptions}
+ |
+
+ );
+ }
+
+ protected renderInputBox(
+ label: string,
+ ref: React.RefObject,
+ value: string,
+ placeholder: string,
+ onChange: (state: OptionState, value: string) => void
+ ): JSX.Element {
+ return (
+
+ {label}
+
+ this.props.resetState(state => onChange(state, ref.current.value), false)
+ }
+ onBlur={() => this.props.resetState(null, true)}
+ />
+
+ );
+ }
+
+ protected renderCheckBox(
+ label: string,
+ ref: React.RefObject,
+ value: boolean,
+ onChange: (state: OptionState, value: boolean) => void
+ ): JSX.Element {
+ return (
+
+
+ this.props.resetState(state => onChange(state, ref.current.checked), true)
+ }
+ onBlur={() => this.props.resetState(null, true)}
+ />
+ {label}
+
+ );
+ }
+
+ private onPluginClick = (id: PluginKey) => {
+ this.props.resetState(state => {
+ let checkbox = document.getElementById(id) as HTMLInputElement;
+ state.pluginList[id] = checkbox.checked;
+ }, true);
+ };
+}
+
+export class Plugins extends PluginsBase {
+ private allowExcelNoBorderTable = React.createRef();
+ private handleTabKey = React.createRef();
+ private handleEnterKey = React.createRef();
+ private listMenu = React.createRef();
+ private tableMenu = React.createRef();
+ private imageMenu = React.createRef();
+ private watermarkText = React.createRef();
+ private autoBullet = React.createRef();
+ private autoNumbering = React.createRef();
+ private autoLink = React.createRef();
+ private autoUnlink = React.createRef();
+ private autoHyphen = React.createRef();
+ private autoFraction = React.createRef();
+ private autoOrdinals = React.createRef();
+ private autoTel = React.createRef();
+ private autoMailto = React.createRef();
+ private removeListMargins = React.createRef();
+ private horizontalLine = React.createRef();
+ private markdownBold = React.createRef();
+ private markdownItalic = React.createRef();
+ private markdownStrikethrough = React.createRef();
+ private markdownCode = React.createRef();
+ private linkTitle = React.createRef();
+
+ render(): JSX.Element {
+ return (
+
+
+ {this.renderPluginItem(
+ 'autoFormat',
+ 'AutoFormat',
+ <>
+ {this.renderCheckBox(
+ 'Bullet',
+ this.autoBullet,
+ this.props.state.autoFormatOptions.autoBullet,
+ (state, value) => (state.autoFormatOptions.autoBullet = value)
+ )}
+ {this.renderCheckBox(
+ 'Numbering',
+ this.autoNumbering,
+ this.props.state.autoFormatOptions.autoNumbering,
+ (state, value) => (state.autoFormatOptions.autoNumbering = value)
+ )}
+ {this.renderCheckBox(
+ 'Link',
+ this.autoLink,
+ this.props.state.autoFormatOptions.autoLink,
+ (state, value) => (state.autoFormatOptions.autoLink = value)
+ )}
+ {this.renderCheckBox(
+ 'Unlink',
+ this.autoUnlink,
+ this.props.state.autoFormatOptions.autoUnlink,
+ (state, value) => (state.autoFormatOptions.autoUnlink = value)
+ )}
+ {this.renderCheckBox(
+ 'Hyphen',
+ this.autoHyphen,
+ this.props.state.autoFormatOptions.autoHyphen,
+ (state, value) => (state.autoFormatOptions.autoHyphen = value)
+ )}
+ {this.renderCheckBox(
+ 'Fraction',
+ this.autoFraction,
+ this.props.state.autoFormatOptions.autoFraction,
+ (state, value) => (state.autoFormatOptions.autoFraction = value)
+ )}
+ {this.renderCheckBox(
+ 'Ordinals',
+ this.autoOrdinals,
+ this.props.state.autoFormatOptions.autoOrdinals,
+ (state, value) => (state.autoFormatOptions.autoOrdinals = value)
+ )}
+ {this.renderCheckBox(
+ 'Telephone',
+ this.autoTel,
+ this.props.state.autoFormatOptions.autoTel,
+ (state, value) => (state.autoFormatOptions.autoTel = value)
+ )}
+ {this.renderCheckBox(
+ 'Email',
+ this.autoMailto,
+ this.props.state.autoFormatOptions.autoMailto,
+ (state, value) => (state.autoFormatOptions.autoMailto = value)
+ )}
+ {this.renderCheckBox(
+ 'Remove List Margins',
+ this.removeListMargins,
+ this.props.state.autoFormatOptions.removeListMargins,
+ (state, value) =>
+ (state.autoFormatOptions.removeListMargins = value)
+ )}
+ {this.renderCheckBox(
+ 'Horizontal Line',
+ this.horizontalLine,
+ this.props.state.autoFormatOptions.autoHorizontalLine,
+ (state, value) =>
+ (state.autoFormatOptions.autoHorizontalLine = value)
+ )}
+ >
+ )}
+ {this.renderPluginItem(
+ 'edit',
+ 'Edit',
+ <>
+ {this.renderCheckBox(
+ 'Handle Tab Key',
+ this.handleTabKey,
+ this.props.state.editPluginOptions.handleTabKey,
+ (state, value) => (state.editPluginOptions.handleTabKey = value)
+ )}
+ {this.renderCheckBox(
+ 'Handle Enter Key',
+ this.handleEnterKey,
+ this.props.state.editPluginOptions.shouldHandleEnterKey as boolean,
+ (state, value) =>
+ (state.editPluginOptions.shouldHandleEnterKey = value)
+ )}
+ >
+ )}
+ {this.renderPluginItem(
+ 'paste',
+ 'Paste',
+ this.renderCheckBox(
+ 'Do not add border for Excel table',
+ this.allowExcelNoBorderTable,
+ this.props.state.allowExcelNoBorderTable,
+ (state, value) => (state.allowExcelNoBorderTable = value)
+ )
+ )}
+ {this.renderPluginItem('shortcut', 'Shortcut')}
+ {this.renderPluginItem('tableEdit', 'TableEdit')}
+ {this.renderPluginItem(
+ 'contextMenu',
+ 'ContextMenu',
+ <>
+ {this.renderCheckBox(
+ 'List menu',
+ this.listMenu,
+ this.props.state.listMenu,
+ (state, value) => (state.listMenu = value)
+ )}
+ {this.renderCheckBox(
+ 'Table menu',
+ this.tableMenu,
+ this.props.state.tableMenu,
+ (state, value) => (state.tableMenu = value)
+ )}
+ {this.renderCheckBox(
+ 'Image menu',
+ this.imageMenu,
+ this.props.state.imageMenu,
+ (state, value) => (state.imageMenu = value)
+ )}
+ >
+ )}
+ {this.renderPluginItem(
+ 'watermark',
+ 'Watermark Plugin',
+ this.renderInputBox(
+ 'Watermark text: ',
+ this.watermarkText,
+ this.props.state.watermarkText,
+ '',
+ (state, value) => (state.watermarkText = value)
+ )
+ )}
+ {this.renderPluginItem('emoji', 'Emoji')}
+ {this.renderPluginItem('pasteOption', 'PasteOptions')}
+ {this.renderPluginItem('sampleEntity', 'SampleEntity')}
+ {this.renderPluginItem(
+ 'markdown',
+ 'Markdown',
+ <>
+ {this.renderCheckBox(
+ 'Bold',
+ this.markdownBold,
+ this.props.state.markdownOptions.bold,
+ (state, value) => (state.markdownOptions.bold = value)
+ )}
+ {this.renderCheckBox(
+ 'Italic',
+ this.markdownItalic,
+ this.props.state.markdownOptions.italic,
+ (state, value) => (state.markdownOptions.italic = value)
+ )}
+ {this.renderCheckBox(
+ 'Strikethrough',
+ this.markdownStrikethrough,
+ this.props.state.markdownOptions.strikethrough,
+ (state, value) => (state.markdownOptions.strikethrough = value)
+ )}
+
+ {this.renderCheckBox(
+ 'Code',
+ this.markdownCode,
+ this.props.state.markdownOptions.codeFormat !== undefined,
+ (state, value) =>
+ value
+ ? (state.markdownOptions.codeFormat = {})
+ : (state.markdownOptions.codeFormat = undefined)
+ )}
+ >
+ )}
+ {this.renderPluginItem(
+ 'hyperlink',
+ 'Hyperlink Plugin',
+ this.renderInputBox(
+ 'Label title: ',
+ this.linkTitle,
+ this.props.state.linkTitle,
+ 'Use "' + UrlPlaceholder + '" for the url string',
+ (state, value) => (state.linkTitle = value)
+ )
+ )}
+ {this.renderPluginItem('customReplace', 'Custom Replace')}
+ {this.renderPluginItem('imageEditPlugin', 'ImageEditPlugin')}
+ {this.renderPluginItem('hiddenProperty', 'Hidden Property')}
+
+
+ );
+ }
+}
diff --git a/src/components/RoosterEditor/editorOptions/codes/AutoFormatCode.ts b/src/components/RoosterEditor/editorOptions/codes/AutoFormatCode.ts
new file mode 100644
index 0000000..2cddd39
--- /dev/null
+++ b/src/components/RoosterEditor/editorOptions/codes/AutoFormatCode.ts
@@ -0,0 +1,20 @@
+import { AutoFormatOptions } from 'roosterjs-content-model-plugins';
+import { CodeElement } from './CodeElement';
+
+export class AutoFormatCode extends CodeElement {
+ constructor(private options: AutoFormatOptions) {
+ super();
+ }
+
+ getCode() {
+ return `new roosterjs.AutoFormatPlugin({
+ autoBullet: ${this.options.autoBullet},
+ autoLink: ${this.options.autoLink},
+ autoNumbering: ${this.options.autoNumbering},
+ autoUnlink: ${this.options.autoUnlink},
+ autoHyphen: ${this.options.autoHyphen},
+ autoFraction: ${this.options.autoFraction},
+ autoOrdinals: ${this.options.autoOrdinals},
+ })`;
+ }
+}
diff --git a/src/components/RoosterEditor/editorOptions/codes/ButtonsCode.ts b/src/components/RoosterEditor/editorOptions/codes/ButtonsCode.ts
new file mode 100644
index 0000000..b800b39
--- /dev/null
+++ b/src/components/RoosterEditor/editorOptions/codes/ButtonsCode.ts
@@ -0,0 +1,26 @@
+import { CodeElement } from './CodeElement';
+import { getObjectKeys } from 'roosterjs-content-model-dom';
+
+const codeMap: { [id: string]: string } = {
+ buttonB: 'roosterjs.toggleBold(editor)',
+ buttonI: 'roosterjs.toggleItalic(editor)',
+ buttonU: 'roosterjs.toggleUnderline(editor)',
+ buttonBullet: 'roosterjs.toggleBullet(editor)',
+ buttonNumbering: 'roosterjs.toggleNumbering(editor)',
+ buttonUndo: 'roosterjs.undo(editor)',
+ buttonRedo: 'roosterjs.redo(editor)',
+ buttonTable: 'roosterjs.insertTable(editor, 3, 3)',
+ buttonDark: 'editor.setDarkModeState(!editor.isDarkMode())',
+};
+
+export class ButtonsCode extends CodeElement {
+ getCode() {
+ const map = { ...codeMap };
+ return getObjectKeys(map)
+ .map(
+ id =>
+ `document.getElementById('${id}').addEventListener('click', () => ${map[id]});\n`
+ )
+ .join('');
+ }
+}
diff --git a/src/components/RoosterEditor/editorOptions/codes/CodeElement.ts b/src/components/RoosterEditor/editorOptions/codes/CodeElement.ts
new file mode 100644
index 0000000..958d021
--- /dev/null
+++ b/src/components/RoosterEditor/editorOptions/codes/CodeElement.ts
@@ -0,0 +1,14 @@
+export abstract class CodeElement {
+ abstract getCode(): string;
+
+ protected encode(src: string): string {
+ return src.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
+ }
+
+ protected indent(src: string): string {
+ return src
+ .split('\n')
+ .map(line => (line == '' ? '' : ' ' + line + '\n'))
+ .join('');
+ }
+}
diff --git a/src/components/RoosterEditor/editorOptions/codes/DarkModeCode.ts b/src/components/RoosterEditor/editorOptions/codes/DarkModeCode.ts
new file mode 100644
index 0000000..0c2f9a4
--- /dev/null
+++ b/src/components/RoosterEditor/editorOptions/codes/DarkModeCode.ts
@@ -0,0 +1,7 @@
+import { CodeElement } from './CodeElement';
+
+export class DarkModeCode extends CodeElement {
+ getCode() {
+ return 'roosterjs.getDarkColor';
+ }
+}
diff --git a/src/components/RoosterEditor/editorOptions/codes/DefaultFormatCode.ts b/src/components/RoosterEditor/editorOptions/codes/DefaultFormatCode.ts
new file mode 100644
index 0000000..33b5cf6
--- /dev/null
+++ b/src/components/RoosterEditor/editorOptions/codes/DefaultFormatCode.ts
@@ -0,0 +1,31 @@
+import { CodeElement } from './CodeElement';
+import { ContentModelSegmentFormat } from 'roosterjs-content-model-types';
+
+export class DefaultFormatCode extends CodeElement {
+ constructor(private defaultFormat: ContentModelSegmentFormat) {
+ super();
+ }
+
+ getCode() {
+ let {
+ fontWeight,
+ italic,
+ underline,
+ fontFamily,
+ fontSize,
+ textColor,
+ backgroundColor,
+ } = this.defaultFormat;
+ let lines = [
+ fontWeight ? `fontWeight: '${fontWeight}',\n` : null,
+ italic ? 'italic: true,\n' : null,
+ underline ? 'underline: true,\n' : null,
+ fontFamily ? `fontFamily: '${this.encode(fontFamily)}',\n` : null,
+ fontSize ? `fontSize: '${this.encode(fontSize)}',\n` : null,
+ textColor ? `textColor: '${this.encode(textColor)}',\n` : null,
+ backgroundColor ? `backgroundColor: '${this.encode(backgroundColor)}',\n` : null,
+ ].filter(line => !!line);
+
+ return lines.length > 0 ? '{\n' + this.indent(lines.join('')) + '}' : '';
+ }
+}
diff --git a/src/components/RoosterEditor/editorOptions/codes/EditorCode.ts b/src/components/RoosterEditor/editorOptions/codes/EditorCode.ts
new file mode 100644
index 0000000..aa425d5
--- /dev/null
+++ b/src/components/RoosterEditor/editorOptions/codes/EditorCode.ts
@@ -0,0 +1,40 @@
+import { ButtonsCode } from './ButtonsCode';
+import { CodeElement } from './CodeElement';
+import { DarkModeCode } from './DarkModeCode';
+import { DefaultFormatCode } from './DefaultFormatCode';
+import { PluginsCode } from './PluginsCode';
+import type { OptionState } from '../OptionState';
+
+export class EditorCode extends CodeElement {
+ private plugins: PluginsCode;
+ private defaultFormat: DefaultFormatCode;
+ private buttons: ButtonsCode;
+ private darkMode: DarkModeCode;
+
+ constructor(state: OptionState) {
+ super();
+
+ this.plugins = new PluginsCode(state);
+ this.defaultFormat = new DefaultFormatCode(state.defaultFormat);
+ this.buttons = new ButtonsCode();
+ this.darkMode = new DarkModeCode();
+ }
+
+ getCode() {
+ let defaultFormat = this.defaultFormat.getCode();
+ let code = "let contentDiv = document.getElementById('contentDiv');\n";
+ let darkMode = this.darkMode.getCode();
+
+ code += `let plugins = ${this.plugins.getCode()};\n`;
+ code += defaultFormat ? `let defaultSegmentFormat = ${defaultFormat};\n` : '';
+ code += 'let options = {\n';
+ code += this.indent('plugins: plugins,\n');
+ code += defaultFormat ? this.indent('defaultSegmentFormat: defaultSegmentFormat,\n') : '';
+ code += this.indent(`getDarkColor: ${darkMode},\n`);
+ code += '};\n';
+ code += `let editor = new roosterjs.Editor(contentDiv, options);\n`;
+ code += this.buttons ? this.buttons.getCode() : '';
+
+ return code;
+ }
+}
diff --git a/src/components/RoosterEditor/editorOptions/codes/HyperLinkCode.ts b/src/components/RoosterEditor/editorOptions/codes/HyperLinkCode.ts
new file mode 100644
index 0000000..f4ad899
--- /dev/null
+++ b/src/components/RoosterEditor/editorOptions/codes/HyperLinkCode.ts
@@ -0,0 +1,32 @@
+import { CodeElement } from './CodeElement';
+import { UrlPlaceholder } from '../OptionState';
+
+export class HyperLinkCode extends CodeElement {
+ constructor(private linkTitle: string) {
+ super();
+ }
+
+ getCode() {
+ return 'new roosterjs.HyperlinkPlugin(' + this.getLinkCallback() + ')';
+ }
+
+ private getLinkCallback() {
+ if (!this.linkTitle) {
+ return '';
+ }
+
+ let index = this.linkTitle.indexOf(UrlPlaceholder);
+ if (index >= 0) {
+ let left = this.linkTitle.substr(0, index);
+ let right = this.linkTitle.substr(index + UrlPlaceholder.length);
+ return (
+ 'url => ' +
+ (left ? `'${this.encode(left)}' + ` : '') +
+ 'url' +
+ (right ? ` + '${this.encode(right)}'` : '')
+ );
+ } else {
+ return `() => '${this.linkTitle}'`;
+ }
+ }
+}
diff --git a/src/components/RoosterEditor/editorOptions/codes/MarkdownCode.ts b/src/components/RoosterEditor/editorOptions/codes/MarkdownCode.ts
new file mode 100644
index 0000000..f089888
--- /dev/null
+++ b/src/components/RoosterEditor/editorOptions/codes/MarkdownCode.ts
@@ -0,0 +1,17 @@
+import { CodeElement } from './CodeElement';
+import { MarkdownOptions } from 'roosterjs-content-model-plugins';
+
+export class MarkdownCode extends CodeElement {
+ constructor(private markdownOptions: MarkdownOptions) {
+ super();
+ }
+
+ getCode() {
+ return `new roosterjs.MarkdownPlugin({
+ bold: ${this.markdownOptions.bold},
+ italic: ${this.markdownOptions.italic},
+ strikethrough: ${this.markdownOptions.strikethrough},
+ codeFormat: ${JSON.stringify(this.markdownOptions.codeFormat)},
+ })`;
+ }
+}
diff --git a/src/components/RoosterEditor/editorOptions/codes/PluginsCode.ts b/src/components/RoosterEditor/editorOptions/codes/PluginsCode.ts
new file mode 100644
index 0000000..63cbde5
--- /dev/null
+++ b/src/components/RoosterEditor/editorOptions/codes/PluginsCode.ts
@@ -0,0 +1,50 @@
+import { AutoFormatCode } from './AutoFormatCode';
+import { CodeElement } from './CodeElement';
+import { MarkdownCode } from './MarkdownCode';
+import { OptionState } from '../OptionState';
+import { WatermarkCode } from './WatermarkCode';
+
+import {
+ EditPluginCode,
+ PastePluginCode,
+ TableEditPluginCode,
+ ShortcutPluginCode,
+ ImageEditPluginCode,
+} from './SimplePluginCode';
+
+export class PluginsCodeBase extends CodeElement {
+ private plugins: CodeElement[];
+ constructor(plugins: CodeElement[]) {
+ super();
+
+ this.plugins = plugins.filter(plugin => !!plugin);
+ }
+
+ getPluginCount() {
+ return this.plugins.length;
+ }
+
+ getCode() {
+ let code = '[\n';
+ code += this.indent(this.plugins.map(plugin => plugin.getCode() + ',\n').join(''));
+ code += ']';
+ return code;
+ }
+}
+
+export class PluginsCode extends PluginsCodeBase {
+ constructor(state: OptionState) {
+ const pluginList = state.pluginList;
+
+ super([
+ pluginList.autoFormat && new AutoFormatCode(state.autoFormatOptions),
+ pluginList.edit && new EditPluginCode(),
+ pluginList.paste && new PastePluginCode(),
+ pluginList.tableEdit && new TableEditPluginCode(),
+ pluginList.shortcut && new ShortcutPluginCode(),
+ pluginList.watermark && new WatermarkCode(state.watermarkText),
+ pluginList.markdown && new MarkdownCode(state.markdownOptions),
+ pluginList.imageEditPlugin && new ImageEditPluginCode(),
+ ]);
+ }
+}
diff --git a/src/components/RoosterEditor/editorOptions/codes/SimplePluginCode.ts b/src/components/RoosterEditor/editorOptions/codes/SimplePluginCode.ts
new file mode 100644
index 0000000..b078ab5
--- /dev/null
+++ b/src/components/RoosterEditor/editorOptions/codes/SimplePluginCode.ts
@@ -0,0 +1,41 @@
+import { CodeElement } from './CodeElement';
+
+class SimplePluginCode extends CodeElement {
+ constructor(private name: string, private namespace: string = 'roosterjs') {
+ super();
+ }
+
+ getCode() {
+ return `new ${this.namespace}.${this.name}()`;
+ }
+}
+
+export class EditPluginCode extends SimplePluginCode {
+ constructor() {
+ super('EditPlugin');
+ }
+}
+
+export class PastePluginCode extends SimplePluginCode {
+ constructor() {
+ super('PastePlugin');
+ }
+}
+
+export class ShortcutPluginCode extends SimplePluginCode {
+ constructor() {
+ super('ShortcutPlugin');
+ }
+}
+
+export class TableEditPluginCode extends SimplePluginCode {
+ constructor() {
+ super('TableEditPlugin');
+ }
+}
+
+export class ImageEditPluginCode extends SimplePluginCode {
+ constructor() {
+ super('ImageEditPlugin');
+ }
+}
diff --git a/src/components/RoosterEditor/editorOptions/codes/WatermarkCode.ts b/src/components/RoosterEditor/editorOptions/codes/WatermarkCode.ts
new file mode 100644
index 0000000..9a7243c
--- /dev/null
+++ b/src/components/RoosterEditor/editorOptions/codes/WatermarkCode.ts
@@ -0,0 +1,11 @@
+import { CodeElement } from './CodeElement';
+
+export class WatermarkCode extends CodeElement {
+ constructor(private watermarkText: string) {
+ super();
+ }
+
+ getCode() {
+ return `new roosterjs.WatermarkPlugin('${this.encode(this.watermarkText)}')`;
+ }
+}
diff --git a/src/components/RoosterEditor/editorOptions/getReplacements.ts b/src/components/RoosterEditor/editorOptions/getReplacements.ts
new file mode 100644
index 0000000..1a9e243
--- /dev/null
+++ b/src/components/RoosterEditor/editorOptions/getReplacements.ts
@@ -0,0 +1,37 @@
+import { ContentModelText } from 'roosterjs-content-model-types';
+import { CustomReplace } from 'roosterjs-content-model-plugins';
+
+function replaceEmojis(
+ previousSegment: ContentModelText,
+ stringToReplace: string,
+ replacement: string
+) {
+ const { text } = previousSegment;
+ const queryString = text.split(' ').pop();
+ if (queryString === stringToReplace) {
+ previousSegment.text = text.replace(stringToReplace, replacement);
+ return true;
+ }
+ return false;
+}
+
+function makeEmojiReplacements(stringToReplace: string, replacement: string) {
+ return {
+ stringToReplace,
+ replacementString: replacement,
+ replacementHandler: replaceEmojis,
+ };
+}
+
+export const emojiReplacements: CustomReplace[] = [
+ makeEmojiReplacements(';)', '😉'),
+ makeEmojiReplacements(';-)', '😉'),
+ makeEmojiReplacements(';P', '😜'),
+ makeEmojiReplacements(';-P', '😜'),
+ makeEmojiReplacements('<3', '❤️'),
+ makeEmojiReplacements('3', '💔'),
+ makeEmojiReplacements(';*', '😘'),
+ makeEmojiReplacements(';-*', '😘'),
+ makeEmojiReplacements('B)', '😎'),
+ makeEmojiReplacements('B-)', '😎'),
+];
diff --git a/src/components/RoosterEditor/index.jsx b/src/components/RoosterEditor/index.jsx
new file mode 100644
index 0000000..0a395c6
--- /dev/null
+++ b/src/components/RoosterEditor/index.jsx
@@ -0,0 +1,117 @@
+import React, { useRef, useEffect, useState, useCallback } from 'react';
+import { createEditor } from 'roosterjs';
+import { Button, Space, Typography, Radio, Input } from 'antd'; // Ant Design components
+import './RoosterEditor.css'; // We'll create this for basic styling
+
+const { Paragraph, Title } = Typography;
+
+const RoosterEditor = ({ initialContent = '', onChange }) => {
+ const editorDivRef = useRef(null);
+ const editorRef = useRef(null);
+ const [editorContent, setEditorContent] = useState(initialContent);
+ const [outputFormat, setOutputFormat] = useState('html');
+
+ // Initialize the editor
+ useEffect(() => {
+ if (editorDivRef.current && !editorRef.current) {
+ const options = {
+ initialContent: editorContent,
+ // THIS IS THE KEY CHANGE: Use onContentChanged event in options
+ // This callback will be triggered by RoosterJs whenever content changes
+ onContentChanged: (event) => {
+ // event is of type ContentChangedEvent (though we don't declare type in JS)
+ const currentHtml = editorRef.current?.getContent() || '';
+ setEditorContent(currentHtml);
+ if (onChange) {
+ onChange(currentHtml); // Call parent's onChange if provided
+ }
+ },
+ };
+ editorRef.current = createEditor(editorDivRef.current, options);
+
+ // Cleanup function to dispose of the editor when component unmounts
+ return () => {
+ if (editorRef.current) {
+ editorRef.current.dispose();
+ editorRef.current = null;
+ }
+ };
+ }
+ }, []); // Empty dependency array means this runs once on mount
+
+ // Function to execute editor commands
+ const executeCommand = useCallback((command, value) => {
+ if (editorRef.current) {
+ editorRef.current.focus();
+ editorRef.current.executeEdit(command, value);
+ }
+ }, []);
+
+ // Function to set content programmatically
+ const setContentProgrammatically = useCallback((newContent) => {
+ if (editorRef.current) {
+ editorRef.current.setContent(newContent);
+ setEditorContent(newContent);
+ if (onChange) {
+ onChange(newContent);
+ }
+ }
+ }, [onChange]);
+
+ const handleFormatChange = (e) => {
+ setOutputFormat(e.target.value);
+ };
+
+ return (
+
+ {/*
RoosterJs Custom Editor with Ant Design */}
+
+
+
+
+
+
+
+
+
+
+
+ Show HTML Output
+ Show Plain Text Output
+
+
+
+
+
+ {/*
+ Current Editor Content (from React state, output as {outputFormat.toUpperCase()}):
+
+
*/}
+
+
+
+ );};
+
+export default RoosterEditor;
diff --git a/src/components/RoosterEditor/options/defaultDomToModelOption.ts b/src/components/RoosterEditor/options/defaultDomToModelOption.ts
new file mode 100644
index 0000000..0e26c3a
--- /dev/null
+++ b/src/components/RoosterEditor/options/defaultDomToModelOption.ts
@@ -0,0 +1,8 @@
+import { demoUndeletableAnchorParser } from './demoUndeletableAnchorParser';
+import { DomToModelOption } from 'roosterjs-content-model-types';
+
+export const defaultDomToModelOption: DomToModelOption = {
+ additionalFormatParsers: {
+ link: [demoUndeletableAnchorParser],
+ },
+};
diff --git a/src/components/RoosterEditor/options/demoUndeletableAnchorParser.ts b/src/components/RoosterEditor/options/demoUndeletableAnchorParser.ts
new file mode 100644
index 0000000..1e92016
--- /dev/null
+++ b/src/components/RoosterEditor/options/demoUndeletableAnchorParser.ts
@@ -0,0 +1,13 @@
+import { FormatParser, UndeletableFormat } from 'roosterjs-content-model-types';
+
+export const DemoUndeletableName = 'DemoUndeletable';
+
+export function undeletableLinkChecker(a: HTMLAnchorElement): boolean {
+ return a.getAttribute('name') == DemoUndeletableName;
+}
+
+export const demoUndeletableAnchorParser: FormatParser = (format, element) => {
+ if (undeletableLinkChecker(element as HTMLAnchorElement)) {
+ format.undeletable = true;
+ }
+};
diff --git a/src/components/RoosterEditor/plugins/FormatPainterPlugin.ts b/src/components/RoosterEditor/plugins/FormatPainterPlugin.ts
new file mode 100644
index 0000000..8c2798a
--- /dev/null
+++ b/src/components/RoosterEditor/plugins/FormatPainterPlugin.ts
@@ -0,0 +1,91 @@
+import { applySegmentFormat, getFormatState } from 'roosterjs-content-model-api';
+import {
+ ContentModelSegmentFormat,
+ EditorPlugin,
+ IEditor,
+ PluginEvent,
+} from 'roosterjs-content-model-types';
+
+const FORMATPAINTERCURSOR_SVG = require('./formatpaintercursor.svg');
+const FORMATPAINTERCURSOR_STYLE = `cursor: url("${FORMATPAINTERCURSOR_SVG}") 8.5 16, auto`;
+const FORMAT_PAINTER_STYLE_KEY = '_FormatPainter';
+
+/**
+ * Format painter handler works together with a format painter button tot let implement format painter functioinality
+ */
+export interface FormatPainterHandler {
+ /**
+ * Let editor enter format painter state
+ */
+ startFormatPainter(): void;
+}
+
+/**
+ * Format painter plugin helps implement format painter functionality.
+ * To use this plugin, you need a button to let editor enter format painter state by calling formatPainterPlugin.startFormatPainter(),
+ * then this plugin will handle the rest work.
+ */
+export class FormatPainterPlugin implements EditorPlugin, FormatPainterHandler {
+ private editor: IEditor | null = null;
+ private painterFormat: ContentModelSegmentFormat | null = null;
+
+ getName() {
+ return 'FormatPainter';
+ }
+
+ initialize(editor: IEditor) {
+ this.editor = editor;
+ }
+
+ dispose() {
+ this.editor = null;
+ }
+
+ onPluginEvent(event: PluginEvent) {
+ if (this.editor && event.eventType == 'mouseUp') {
+ if (this.painterFormat) {
+ applySegmentFormat(this.editor, this.painterFormat);
+
+ this.setFormatPainterCursor(null);
+ }
+ }
+ }
+
+ private setFormatPainterCursor(format: ContentModelSegmentFormat | null) {
+ this.painterFormat = format;
+
+ this.editor?.setEditorStyle(
+ FORMAT_PAINTER_STYLE_KEY,
+ this.painterFormat ? FORMATPAINTERCURSOR_STYLE : null
+ );
+ }
+
+ startFormatPainter() {
+ if (this.editor) {
+ const format = getSegmentFormat(this.editor);
+
+ this.setFormatPainterCursor(format);
+ }
+ }
+}
+
+function getSegmentFormat(editor: IEditor): ContentModelSegmentFormat {
+ const formatState = getFormatState(editor);
+
+ return {
+ backgroundColor: formatState.backgroundColor,
+ fontFamily: formatState.fontName,
+ fontSize: formatState.fontSize,
+ fontWeight: formatState.isBold ? 'bold' : 'normal',
+ italic: formatState.isItalic,
+ letterSpacing: formatState.letterSpacing,
+ strikethrough: formatState.isStrikeThrough,
+ superOrSubScriptSequence: formatState.isSubscript
+ ? 'sub'
+ : formatState.isSuperscript
+ ? 'super'
+ : '',
+ textColor: formatState.textColor,
+ underline: formatState.isUnderline,
+ };
+}
diff --git a/src/components/RoosterEditor/plugins/SampleEntityPlugin.ts b/src/components/RoosterEditor/plugins/SampleEntityPlugin.ts
new file mode 100644
index 0000000..84a559e
--- /dev/null
+++ b/src/components/RoosterEditor/plugins/SampleEntityPlugin.ts
@@ -0,0 +1,156 @@
+import { insertEntity } from 'roosterjs-content-model-api';
+import type {
+ EditorPlugin,
+ Entity,
+ EntityState,
+ IEditor,
+ PluginEvent,
+} from 'roosterjs-content-model-types';
+
+const EntityType = 'SampleEntity';
+
+interface EntityMetadata {
+ count: number;
+}
+
+export default class SampleEntityPlugin implements EditorPlugin {
+ private editor: IEditor;
+ private hydratedEntities: Record = {};
+
+ getName() {
+ return 'SampleEntity';
+ }
+
+ initialize(editor: IEditor) {
+ this.editor = editor;
+ }
+
+ dispose() {
+ this.editor = null;
+ }
+
+ onPluginEvent(event: PluginEvent) {
+ if (event.eventType == 'keyDown' && event.rawEvent.key == 'm' && event.rawEvent.ctrlKey) {
+ insertEntity(this.editor, EntityType, true /*isBlock*/, 'focus', {
+ contentNode: this.createEntityNode(),
+ initialEntityState: '{}',
+ });
+
+ event.rawEvent.preventDefault();
+ } else if (event.eventType == 'entityOperation' && event.entity.type == EntityType) {
+ const entity = event.entity;
+ const hydratedEntity = this.hydratedEntities[entity.id];
+
+ switch (event.operation) {
+ case 'newEntity':
+ hydratedEntity?.dehydrate();
+ this.hydratedEntities[entity.id] = new HydratedEntity(entity, this.onClick);
+
+ break;
+
+ case 'removeFromEnd':
+ case 'removeFromStart':
+ case 'overwrite':
+ case 'replaceTemporaryContent':
+ hydratedEntity?.dehydrate();
+
+ break;
+
+ case 'updateEntityState':
+ if (event.state) {
+ setMetadata(event.entity.wrapper, JSON.parse(event.state));
+ hydratedEntity?.update();
+ }
+
+ break;
+
+ case 'beforeFormat':
+ const span = entity.wrapper.querySelector('span');
+
+ if (span && event.formattableRoots) {
+ event.formattableRoots.push({
+ element: span,
+ });
+ }
+ break;
+ }
+ }
+ }
+
+ private onClick = (state: EntityState) => {
+ this.editor.takeSnapshot(state);
+ };
+
+ private createEntityNode() {
+ const div = document.createElement('div');
+
+ return div;
+ }
+}
+
+class HydratedEntity {
+ constructor(private entity: Entity, private onClick: (entityState: EntityState) => void) {
+ const containerDiv = entity.wrapper.querySelector('div');
+ const span = document.createElement('span');
+ const button = document.createElement('button');
+
+ containerDiv.appendChild(span);
+ containerDiv.appendChild(button);
+
+ button.textContent = 'Test entity';
+ button.addEventListener('click', this.onClickEntity);
+
+ this.update();
+ }
+
+ update(increase: number = 0) {
+ const metadata = getMetadata(this.entity.wrapper);
+ const count = (metadata?.count || 0) + increase;
+
+ setMetadata(this.entity.wrapper, {
+ count,
+ });
+
+ this.entity.wrapper.querySelector('span').textContent = 'Count: ' + count;
+ }
+
+ dehydrate() {
+ const containerDiv = this.entity.wrapper.querySelector('div');
+ const button = containerDiv.querySelector('button');
+
+ if (button) {
+ button.removeEventListener('click', this.onClickEntity);
+ containerDiv.removeChild(button);
+ }
+ }
+
+ private onClickEntity = (e: MouseEvent) => {
+ this.update(1);
+ this.onClick({
+ id: this.entity.id,
+ type: this.entity.type,
+ state: this.entity.wrapper.dataset.editingInfo,
+ });
+ };
+}
+
+const MetadataDataSetName = 'editingInfo';
+
+function getMetadata(element: HTMLElement): T | null {
+ const str = element.dataset[MetadataDataSetName];
+ let obj: any;
+
+ try {
+ obj = str ? JSON.parse(str) : null;
+ } catch {}
+
+ if (typeof obj !== 'undefined') {
+ return obj as T;
+ } else {
+ return null;
+ }
+}
+
+function setMetadata(element: HTMLElement, metadata: T) {
+ element.dataset[MetadataDataSetName] = JSON.stringify(metadata);
+}
diff --git a/src/components/RoosterEditor/plugins/SamplePickerPlugin.tsx b/src/components/RoosterEditor/plugins/SamplePickerPlugin.tsx
new file mode 100644
index 0000000..6df1ff3
--- /dev/null
+++ b/src/components/RoosterEditor/plugins/SamplePickerPlugin.tsx
@@ -0,0 +1,206 @@
+import * as React from 'react';
+import { Callout } from '@fluentui/react/lib/Callout';
+import { DOMInsertPoint } from 'roosterjs-content-model-types';
+import { IContextualMenuItem } from '@fluentui/react/lib/ContextualMenu';
+import { mergeStyles } from '@fluentui/react/lib/Styling';
+import { ReactEditorPlugin, UIUtilities } from 'roosterjs-react';
+import {
+ PickerDirection,
+ PickerHandler,
+ PickerHelper,
+ PickerPlugin,
+ PickerSelectionChangMode,
+} from 'roosterjs-content-model-plugins';
+import {
+ createContentModelDocument,
+ createEntity,
+ createParagraph,
+ getDOMInsertPointRect,
+} from 'roosterjs-content-model-dom';
+
+const itemStyle = mergeStyles({
+ height: '20px',
+ margin: '4px',
+ padding: '4px',
+ minWidth: '200px',
+});
+
+const selectedItemStyle = mergeStyles({
+ backgroundColor: 'blue',
+ color: 'white',
+ fontWeight: 'bold',
+});
+
+export class SamplePickerPlugin extends PickerPlugin implements ReactEditorPlugin {
+ private pickerHandler: SamplePickerHandler;
+
+ constructor() {
+ const pickerHandler = new SamplePickerHandler();
+ super('@', pickerHandler);
+
+ this.pickerHandler = pickerHandler;
+ }
+
+ setUIUtilities(uiUtilities: UIUtilities): void {
+ this.pickerHandler.setUIUtilities(uiUtilities);
+ }
+}
+
+class SamplePickerHandler implements PickerHandler {
+ private uiUtilities: UIUtilities;
+ private index = 0;
+ private ref: IPickerMenu | null = null;
+ private queryString: string;
+ private items: IContextualMenuItem[] = [];
+ private onClose: (() => void) | null = null;
+ private helper: PickerHelper | null = null;
+
+ onInitialize(helper: PickerHelper) {
+ this.helper = helper;
+ }
+
+ onDispose() {
+ this.helper = null;
+ }
+
+ setUIUtilities(uiUtilities: UIUtilities): void {
+ this.uiUtilities = uiUtilities;
+ }
+
+ onTrigger(queryString: string, pos: DOMInsertPoint): PickerDirection | null {
+ this.index = 0;
+ this.queryString = queryString;
+ this.items = buildItems(queryString, this.index);
+
+ const rect = getDOMInsertPointRect(this.helper.editor.getDocument(), pos);
+
+ if (rect) {
+ this.onClose = this.uiUtilities.renderComponent(
+ (this.ref = ref)}
+ items={this.items}
+ />
+ );
+ return 'vertical';
+ } else {
+ return null;
+ }
+ }
+
+ onClosePicker() {
+ this.onClose?.();
+ this.onClose = null;
+ }
+
+ onSelectionChanged(mode: PickerSelectionChangMode): void {
+ switch (mode) {
+ case 'first':
+ case 'firstInRow':
+ case 'previousPage':
+ this.index = 0;
+ break;
+
+ case 'last':
+ case 'lastInRow':
+ case 'nextPage':
+ this.index = 4;
+ break;
+
+ case 'previous':
+ this.index = this.index - 1;
+
+ if (this.index < 0) {
+ this.index = 4;
+ }
+
+ break;
+
+ case 'next':
+ this.index = (this.index + 1) % 5;
+ break;
+ }
+
+ this.items = buildItems(this.queryString, this.index);
+ this.ref?.setMenuItems(this.items);
+ }
+
+ onSelect(): void {
+ const text = this.items[this.index]?.text;
+
+ if (text) {
+ const span = this.helper.editor.getDocument().createElement('span');
+ span.textContent = '@' + text;
+ span.style.textDecoration = 'underline';
+ span.style.color = 'blue';
+
+ const entity = createEntity(span, true /*isReadonly*/, {}, 'TEST_ENTITY');
+ const paragraph = createParagraph();
+ const doc = createContentModelDocument();
+
+ paragraph.segments.push(entity);
+ doc.blocks.push(paragraph);
+
+ this.helper.replaceQueryString(
+ doc,
+ {
+ changeSource: 'SamplePicker',
+ },
+ true /*canUndoByBackspace*/
+ );
+ }
+
+ this.onClose?.();
+ this.onClose = null;
+ this.ref = null;
+ this.helper.closePicker();
+ }
+
+ onQueryStringChanged(queryString: string): void {
+ this.queryString = queryString;
+
+ if (queryString.length > 100 || queryString.split(' ').length > 4) {
+ // Querystring is too long, so close picker
+ this.helper.closePicker();
+ } else {
+ this.items = buildItems(this.queryString, this.index);
+ this.ref?.setMenuItems(this.items);
+ }
+ }
+}
+
+function buildItems(queryString: string, index: number): IContextualMenuItem[] {
+ return [1, 2, 3, 4, 5].map((x, i) => ({
+ key: 'item' + i,
+ text: queryString.substring(1) + ' item ' + x,
+ checked: i == index,
+ }));
+}
+
+interface IPickerMenu {
+ setMenuItems: (items: IContextualMenuItem[]) => void;
+}
+
+const PickerMenu = React.forwardRef(
+ (
+ props: { x: number; y: number; items: IContextualMenuItem[] },
+ ref: React.Ref
+ ) => {
+ const [items, setItems] = React.useState(props.items);
+
+ React.useImperativeHandle(ref, () => ({
+ setMenuItems: setItems,
+ }));
+
+ return (
+
+ {items.map(item => (
+
+ {item.text}
+
+ ))}
+
+ );
+ }
+);
diff --git a/src/components/RoosterEditor/plugins/UpdateContentPlugin.ts b/src/components/RoosterEditor/plugins/UpdateContentPlugin.ts
new file mode 100644
index 0000000..c4eef96
--- /dev/null
+++ b/src/components/RoosterEditor/plugins/UpdateContentPlugin.ts
@@ -0,0 +1,60 @@
+import type {
+ ContentModelDocument,
+ EditorPlugin,
+ IEditor,
+ PluginEvent,
+} from 'roosterjs-content-model-types';
+
+/**
+ * A plugin to help get HTML content from editor
+ */
+export class UpdateContentPlugin implements EditorPlugin {
+ private editor: IEditor | null = null;
+
+ /**
+ * Create a new instance of UpdateContentPlugin class
+ * @param onUpdate A callback to be invoked when update happens
+ */
+ constructor(private onUpdate: (model: ContentModelDocument) => void) {}
+
+ /**
+ * Get a friendly name of this plugin
+ */
+ getName() {
+ return 'UpdateContent';
+ }
+
+ /**
+ * Initialize this plugin
+ * @param editor The editor instance
+ */
+ initialize(editor: IEditor) {
+ this.editor = editor;
+ }
+
+ /**
+ * Dispose this plugin
+ */
+ dispose() {
+ this.editor = null;
+ }
+
+ /**
+ * Handle events triggered from editor
+ * @param event PluginEvent object
+ */
+ onPluginEvent(event: PluginEvent) {
+ switch (event.eventType) {
+ case 'beforeDispose':
+ this.update();
+ break;
+ }
+ }
+
+ update() {
+ if (this.editor) {
+ const model = this.editor.getContentModelCopy('disconnected');
+ this.onUpdate(model);
+ }
+ }
+}
diff --git a/src/components/RoosterEditor/plugins/formatpaintercursor.svg b/src/components/RoosterEditor/plugins/formatpaintercursor.svg
new file mode 100644
index 0000000..28e4c18
--- /dev/null
+++ b/src/components/RoosterEditor/plugins/formatpaintercursor.svg
@@ -0,0 +1,22 @@
+
\ No newline at end of file
diff --git a/src/components/RoosterEditor/snapshot/SnapshotPane.scss b/src/components/RoosterEditor/snapshot/SnapshotPane.scss
new file mode 100644
index 0000000..246c2c4
--- /dev/null
+++ b/src/components/RoosterEditor/snapshot/SnapshotPane.scss
@@ -0,0 +1,61 @@
+@import '../../theme/theme.scss';
+
+.snapshotPane {
+ flex: 1 1 auto;
+ display: flex;
+ flex-direction: column;
+}
+
+.buttons {
+ margin-bottom: 10px;
+ flex: 0 0 auto;
+}
+
+.textarea {
+ flex: 1 1 auto;
+ resize: none;
+ min-height: 100px;
+ border-color: $primaryBorder;
+}
+
+.input {
+ border-color: $primaryBorder;
+}
+
+.snapshotList {
+ min-height: 100px;
+ max-height: 200px;
+ overflow: hidden auto;
+ border: solid 1px $primaryBorder;
+
+ pre {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ cursor: pointer;
+ margin: 0;
+ &:hover {
+ background-color: #eee;
+ }
+
+ &.current {
+ font-weight: bold;
+ }
+
+ &.autoComplete {
+ background-color: #ff0;
+ }
+ }
+}
+
+@media (prefers-color-scheme: dark) {
+ .snapshotList {
+ border: solid 1px $primaryBorderDark;
+ }
+ .textarea {
+ border-color: $primaryBorderDark;
+ }
+ .snapshotList {
+ border: solid 1px $primaryBorderDark;
+ }
+}
diff --git a/src/components/RoosterEditor/snapshot/SnapshotPane.tsx b/src/components/RoosterEditor/snapshot/SnapshotPane.tsx
new file mode 100644
index 0000000..d503be9
--- /dev/null
+++ b/src/components/RoosterEditor/snapshot/SnapshotPane.tsx
@@ -0,0 +1,188 @@
+import * as React from 'react';
+import { EntityState, Snapshot, SnapshotSelection } from 'roosterjs-content-model-types';
+
+const styles = require('./SnapshotPane.scss');
+
+export interface SnapshotPaneProps {
+ onTakeSnapshot: () => Snapshot;
+ onRestoreSnapshot: (snapshot: Snapshot, triggerContentChangedEvent: boolean) => void;
+ onMove: (moveStep: number) => void;
+}
+
+export interface SnapshotPaneState {
+ snapshots: Snapshot[];
+ currentIndex: number;
+ autoCompleteIndex: number;
+}
+
+export class SnapshotPane extends React.Component {
+ private html = React.createRef();
+ private entityStates = React.createRef();
+ private isDarkColor = React.createRef();
+ private selection = React.createRef();
+ private logicalRootPath = React.createRef();
+
+ constructor(props: SnapshotPaneProps) {
+ super(props);
+
+ this.state = {
+ snapshots: [],
+ currentIndex: -1,
+ autoCompleteIndex: -1,
+ };
+ }
+
+ render() {
+ return (
+
+
Undo Snapshots
+
+ {this.state.snapshots.map(this.renderItem)}
+
+
Selected Snapshot
+
+ {' '}
+
+
+
+
HTML:
+
+
Selection:
+
+
Entity states:
+
+
Logical root path:
+
+
+
+
+
+
+ );
+ }
+
+ private getCurrentSnapshot(): Snapshot {
+ const html = this.html.current.value;
+ const selection = this.selection.current.value
+ ? (JSON.parse(this.selection.current.value) as SnapshotSelection)
+ : undefined;
+ const entityStates = this.entityStates.current.value
+ ? (JSON.parse(this.entityStates.current.value) as EntityState[])
+ : undefined;
+ const logicalRootPath = this.logicalRootPath.current.value
+ ? (JSON.parse(this.logicalRootPath.current.value) as number[])
+ : undefined;
+ const isDarkMode = !!this.isDarkColor.current.checked;
+
+ return {
+ html,
+ entityStates,
+ isDarkMode,
+ selection,
+ logicalRootPath,
+ };
+ }
+
+ private onClickRestoreSnapshot = () => {
+ const snapshot = this.getCurrentSnapshot();
+
+ this.props.onRestoreSnapshot(snapshot, true);
+ };
+
+ private onCopy = () => {
+ const snapshot = this.getCurrentSnapshot();
+ const metadata = {
+ ...snapshot.selection,
+ isDarkMode: snapshot.isDarkMode,
+ logicalRootPath: snapshot.logicalRootPath,
+ };
+ const textToCopy = snapshot.html + ``;
+
+ navigator.clipboard.writeText(textToCopy);
+ };
+
+ private onPaste = (event: React.ClipboardEvent) => {
+ const str = event.clipboardData.getData('text/plain');
+
+ if (str) {
+ const idx = str.lastIndexOf('')) {
+ const html = str.substring(0, idx);
+ const json = str.substring(idx + 4, str.length - 3);
+
+ try {
+ const metadata = JSON.parse(json);
+ const isDarkMode = !!metadata.isDarkMode;
+ const logicalRootPath = metadata.logicalRootPath;
+
+ delete metadata.isDarkMode;
+ delete metadata.logicalRootPath;
+
+ this.setSnapshot({
+ html: html,
+ entityStates: [],
+ isDarkMode,
+ selection: metadata as SnapshotSelection,
+ logicalRootPath,
+ });
+
+ event.preventDefault();
+ } catch {}
+ }
+ }
+ };
+
+ updateSnapshots(snapshots: Snapshot[], currentIndex: number, autoCompleteIndex: number) {
+ this.setState({
+ snapshots,
+ currentIndex,
+ autoCompleteIndex,
+ });
+ }
+
+ snapshotToString(snapshot: Snapshot) {
+ return (
+ snapshot.html +
+ (snapshot.selection ? `` : '')
+ );
+ }
+
+ private takeSnapshot = () => {
+ const snapshot = this.props.onTakeSnapshot();
+ this.setSnapshot(snapshot);
+ };
+
+ private setSnapshot = (snapshot: Snapshot) => {
+ this.html.current.value = snapshot.html;
+ this.entityStates.current.value = snapshot.entityStates
+ ? JSON.stringify(snapshot.entityStates)
+ : '';
+ this.selection.current.value = snapshot.selection ? JSON.stringify(snapshot.selection) : '';
+ this.logicalRootPath.current.value = snapshot.logicalRootPath
+ ? JSON.stringify(snapshot.logicalRootPath)
+ : '';
+ this.isDarkColor.current.checked = !!snapshot.isDarkMode;
+ };
+
+ private renderItem = (snapshot: Snapshot, index: number) => {
+ let className = '';
+ if (index == this.state.currentIndex) {
+ className += ' ' + styles.current;
+ }
+ if (index == this.state.autoCompleteIndex) {
+ className += ' ' + styles.autoComplete;
+ }
+
+ const snapshotStr = this.snapshotToString(snapshot);
+ return (
+ this.setSnapshot(snapshot)}
+ onDoubleClick={() => this.props.onMove(index - this.state.currentIndex)}>
+ {(snapshotStr || '').substring(0, 1000)}
+
+ );
+ };
+}
diff --git a/src/components/RoosterEditor/snapshot/SnapshotPlugin.tsx b/src/components/RoosterEditor/snapshot/SnapshotPlugin.tsx
new file mode 100644
index 0000000..968f93f
--- /dev/null
+++ b/src/components/RoosterEditor/snapshot/SnapshotPlugin.tsx
@@ -0,0 +1,85 @@
+import * as React from 'react';
+import { IEditor, PluginEvent, Snapshot, Snapshots } from 'roosterjs-content-model-types';
+import { SidePanePlugin } from '../SidePanePlugin';
+import { SnapshotPane } from './SnapshotPane';
+
+export class SnapshotPlugin implements SidePanePlugin {
+ private editor: IEditor;
+ private component: SnapshotPane;
+
+ constructor(private snapshots: Snapshots) {
+ this.snapshots.onChanged = () => this.updateSnapshots();
+ }
+
+ getName() {
+ return 'Snapshot';
+ }
+
+ initialize(editor: IEditor) {
+ this.editor = editor;
+ }
+
+ dispose() {
+ this.editor = null;
+ }
+
+ onPluginEvent(e: PluginEvent) {
+ if (e.eventType == 'editorReady') {
+ this.updateSnapshots();
+ }
+ }
+
+ getTitle() {
+ return 'Undo Snapshots';
+ }
+
+ renderSidePane() {
+ return ;
+ }
+
+ getSnapshots() {
+ return this.snapshots;
+ }
+
+ private refCallback = (ref: SnapshotPane) => {
+ this.component = ref;
+ if (ref) {
+ this.updateSnapshots();
+ }
+ };
+
+ private getComponentProps() {
+ return {
+ onRestoreSnapshot: this.onRestoreSnapshot,
+ onTakeSnapshot: this.onTakeSnapshot,
+ onMove: this.onMove,
+ };
+ }
+
+ private onTakeSnapshot = (): Snapshot => {
+ return this.editor.takeSnapshot();
+ };
+
+ private onMove = (step: number) => {
+ const snapshotsManager = this.editor.getSnapshotsManager();
+ const snapshot = snapshotsManager.move(step);
+ this.onRestoreSnapshot(snapshot);
+ };
+
+ private onRestoreSnapshot = (snapshot: Snapshot) => {
+ this.editor.focus();
+ this.editor.restoreSnapshot(snapshot);
+ };
+
+ private updateSnapshots = () => {
+ if (!this.component) {
+ return;
+ }
+
+ this.component.updateSnapshots(
+ this.snapshots.snapshots,
+ this.snapshots.currentIndex,
+ this.snapshots.autoCompleteIndex
+ );
+ };
+}
diff --git a/src/components/RoosterEditor/tabs/getTabs.ts b/src/components/RoosterEditor/tabs/getTabs.ts
new file mode 100644
index 0000000..2d19408
--- /dev/null
+++ b/src/components/RoosterEditor/tabs/getTabs.ts
@@ -0,0 +1,61 @@
+import { MainPane } from '../mainPane/MainPane';
+import type { RibbonButton } from 'roosterjs-react';
+
+const styles = require('../mainPane/MainPane.scss');
+
+export type tabNames = 'all' | 'text' | 'paragraph' | 'insert' | 'table' | 'image';
+
+export type TabMainStringKey = 'tabNameMain';
+export type TabTextStringKey = 'tabNameText';
+export type TabParagraphStringKey = 'tabNameParagraph';
+export type TabInsertStringKey = 'tabNameInsert';
+export type TabTableStringKey = 'tabNameTable';
+export type TabImageStringKey = 'tabNameImage';
+
+export type AllTabStringKeys =
+ | TabTableStringKey
+ | TabTextStringKey
+ | TabParagraphStringKey
+ | TabInsertStringKey
+ | TabMainStringKey
+ | TabImageStringKey;
+
+type TabData = {
+ key: AllTabStringKeys;
+ unlocalizedText: string;
+ name: tabNames;
+};
+
+const TabNames: TabData[] = [
+ { key: 'tabNameMain', unlocalizedText: 'Main', name: 'all' },
+ { key: 'tabNameText', unlocalizedText: 'Text', name: 'text' },
+ { key: 'tabNameParagraph', unlocalizedText: 'Paragraph', name: 'paragraph' },
+ { key: 'tabNameInsert', unlocalizedText: 'Insert', name: 'insert' },
+ { key: 'tabNameTable', unlocalizedText: 'Table', name: 'table' },
+ { key: 'tabNameImage', unlocalizedText: 'Image', name: 'image' },
+];
+
+export function getTabs() {
+ const Tabs: RibbonButton[] = [];
+ TabNames.forEach(tab => {
+ const tabButton: RibbonButton = {
+ key: tab.key,
+ unlocalizedText: tab.unlocalizedText,
+ iconName: '',
+ onClick: () => {
+ MainPane.getInstance().changeRibbon(tab.name);
+ },
+ commandBarProperties: {
+ buttonStyles: {
+ label: { fontWeight: 'bold', paddingTop: '-5px' },
+ textContainer: { paddingBottom: '10px' },
+ },
+ iconOnly: false,
+ className: styles.menuTab,
+ },
+ };
+ Tabs.push(tabButton);
+ });
+
+ return Tabs;
+}
diff --git a/src/components/RoosterEditor/tabs/ribbonButtons.ts b/src/components/RoosterEditor/tabs/ribbonButtons.ts
new file mode 100644
index 0000000..af669df
--- /dev/null
+++ b/src/components/RoosterEditor/tabs/ribbonButtons.ts
@@ -0,0 +1,222 @@
+import { changeImageButton } from '../demoButtons/changeImageButton';
+import { createFormatPainterButton } from '../demoButtons/formatPainterButton';
+import { createImageEditButtons } from '../demoButtons/createImageEditButtons';
+import { FormatPainterPlugin } from '../plugins/FormatPainterPlugin';
+import { formatTableButton } from '../demoButtons/formatTableButton';
+import { imageBorderColorButton } from '../demoButtons/imageBorderColorButton';
+import { imageBorderRemoveButton } from '../demoButtons/imageBorderRemoveButton';
+import { imageBorderStyleButton } from '../demoButtons/imageBorderStyleButton';
+import { imageBorderWidthButton } from '../demoButtons/imageBorderWidthButton';
+import { imageBoxShadowButton } from '../demoButtons/imageBoxShadowButton';
+import { ImageEditor } from 'roosterjs-content-model-types';
+import { listStartNumberButton } from '../demoButtons/listStartNumberButton';
+import { pasteButton } from '../demoButtons/pasteButton';
+import { setBulletedListStyleButton } from '../demoButtons/setBulletedListStyleButton';
+import { setNumberedListStyleButton } from '../demoButtons/setNumberedListStyleButton';
+import { setTableCellShadeButton } from '../demoButtons/setTableCellShadeButton';
+import { spaceAfterButton, spaceBeforeButton } from '../demoButtons/spaceBeforeAfterButtons';
+import { spacingButton } from '../demoButtons/spacingButton';
+import { tableBorderApplyButton } from '../demoButtons/tableBorderApplyButton';
+import { tableBorderColorButton } from '../demoButtons/tableBorderColorButton';
+import { tableBorderStyleButton } from '../demoButtons/tableBorderStyleButton';
+import { tableBorderWidthButton } from '../demoButtons/tableBorderWidthButton';
+import { tableOptionsButton } from '../demoButtons/tableOptionsButton';
+import { tableTitleButton } from '../demoButtons/tableTitleButton';
+import { tabNames } from './getTabs';
+import {
+ tableAlignCellButton,
+ tableAlignTableButton,
+ tableDeleteButton,
+ tableInsertButton,
+ tableMergeButton,
+ tableSplitButton,
+} from '../demoButtons/tableEditButtons';
+import type { RibbonButton } from 'roosterjs-react';
+import {
+ alignCenterButton,
+ alignJustifyButton,
+ alignLeftButton,
+ alignRightButton,
+ backgroundColorButton,
+ blockQuoteButton,
+ boldButton,
+ bulletedListButton,
+ clearFormatButton,
+ codeButton,
+ decreaseFontSizeButton,
+ decreaseIndentButton,
+ fontButton,
+ fontSizeButton,
+ increaseFontSizeButton,
+ increaseIndentButton,
+ insertImageButton,
+ insertLinkButton,
+ insertTableButton,
+ italicButton,
+ ltrButton,
+ numberedListButton,
+ removeLinkButton,
+ rtlButton,
+ setHeadingLevelButton,
+ strikethroughButton,
+ subscriptButton,
+ superscriptButton,
+ textColorButton,
+ underlineButton,
+} from 'roosterjs-react';
+
+const textButtons: RibbonButton[] = [
+ boldButton,
+ italicButton,
+ underlineButton,
+ fontButton,
+ fontSizeButton,
+ increaseFontSizeButton,
+ decreaseFontSizeButton,
+ textColorButton,
+ backgroundColorButton,
+ superscriptButton,
+ subscriptButton,
+ strikethroughButton,
+];
+
+const tableButtons: RibbonButton[] = [
+ insertTableButton,
+ formatTableButton,
+ setTableCellShadeButton,
+ tableTitleButton,
+ tableOptionsButton,
+ tableInsertButton,
+ tableDeleteButton,
+ tableBorderApplyButton,
+ tableBorderColorButton,
+ tableBorderWidthButton,
+ tableBorderStyleButton,
+ tableMergeButton,
+ tableSplitButton,
+ tableAlignCellButton,
+ tableAlignTableButton,
+];
+
+const imageButtons: RibbonButton[] = [
+ insertImageButton,
+ imageBorderColorButton,
+ imageBorderWidthButton,
+ imageBorderStyleButton,
+ imageBorderRemoveButton,
+ changeImageButton,
+ imageBoxShadowButton,
+];
+
+const insertButtons: RibbonButton[] = [
+ insertLinkButton,
+ removeLinkButton,
+ insertTableButton,
+ insertImageButton,
+];
+
+const paragraphButtons: RibbonButton[] = [
+ bulletedListButton,
+ numberedListButton,
+ decreaseIndentButton,
+ increaseIndentButton,
+ blockQuoteButton,
+ alignLeftButton,
+ alignCenterButton,
+ alignRightButton,
+ alignJustifyButton,
+ setHeadingLevelButton,
+ codeButton,
+ ltrButton,
+ rtlButton,
+ clearFormatButton,
+ setBulletedListStyleButton,
+ setNumberedListStyleButton,
+ listStartNumberButton,
+ spacingButton,
+ spaceBeforeButton,
+ spaceAfterButton,
+ pasteButton,
+];
+
+const allButtons: RibbonButton[] = [
+ boldButton,
+ italicButton,
+ underlineButton,
+ fontButton,
+ fontSizeButton,
+ increaseFontSizeButton,
+ decreaseFontSizeButton,
+ textColorButton,
+ backgroundColorButton,
+ bulletedListButton,
+ numberedListButton,
+ decreaseIndentButton,
+ increaseIndentButton,
+ blockQuoteButton,
+ alignLeftButton,
+ alignCenterButton,
+ alignRightButton,
+ alignJustifyButton,
+ insertLinkButton,
+ removeLinkButton,
+ insertTableButton,
+ insertImageButton,
+ superscriptButton,
+ subscriptButton,
+ strikethroughButton,
+ setHeadingLevelButton,
+ codeButton,
+ ltrButton,
+ rtlButton,
+ clearFormatButton,
+ setBulletedListStyleButton,
+ setNumberedListStyleButton,
+ listStartNumberButton,
+ formatTableButton,
+ setTableCellShadeButton,
+ tableOptionsButton,
+ tableInsertButton,
+ tableDeleteButton,
+ tableMergeButton,
+ tableSplitButton,
+ tableTitleButton,
+ tableAlignCellButton,
+ tableAlignTableButton,
+ tableBorderApplyButton,
+ tableBorderColorButton,
+ tableBorderWidthButton,
+ tableBorderStyleButton,
+ imageBorderColorButton,
+ imageBorderWidthButton,
+ imageBorderStyleButton,
+ imageBorderRemoveButton,
+ changeImageButton,
+ imageBoxShadowButton,
+ spacingButton,
+ spaceBeforeButton,
+ spaceAfterButton,
+ pasteButton,
+];
+export function getButtons(
+ id: tabNames,
+ formatPlainerPlugin?: FormatPainterPlugin,
+ imageEditor?: ImageEditor
+) {
+ switch (id) {
+ case 'text':
+ return [createFormatPainterButton(formatPlainerPlugin), ...textButtons];
+ case 'paragraph':
+ return paragraphButtons;
+ case 'insert':
+ return insertButtons;
+ case 'image':
+ return imageEditor
+ ? [...imageButtons, ...createImageEditButtons(imageEditor)]
+ : imageButtons;
+ case 'table':
+ return tableButtons;
+ case 'all':
+ return [createFormatPainterButton(formatPlainerPlugin), ...allButtons];
+ }
+}
diff --git a/src/components/RoosterEditor/theme/theme.scss b/src/components/RoosterEditor/theme/theme.scss
new file mode 100644
index 0000000..8525642
--- /dev/null
+++ b/src/components/RoosterEditor/theme/theme.scss
@@ -0,0 +1,27 @@
+$primaryColor: #cc6688;
+$primaryLighter: lighten($primaryColor, 5%);
+$primaryLighter2: lighten($primaryColor, 50%);
+$primaryBorder: #cc6688;
+$primaryBackgroundColor: white;
+
+$primaryColorDark: #cb6587;
+$primaryLighterDark: lighten($primaryColorDark, 5%);
+$primaryLighter2Dark: lighten($primaryColorDark, 50%);
+$primaryBorderDark: #cb6587;
+$primaryBackgroundColorDark: #333333;
+
+@media (prefers-color-scheme: dark) {
+ button {
+ background-color: $primaryColorDark;
+ color: $primaryLighter2;
+ border: solid 1px $primaryBorderDark;
+ }
+
+ select,
+ input,
+ textarea {
+ background-color: $primaryBackgroundColorDark;
+ color: $primaryLighter2;
+ border: solid 1px $primaryBorderDark;
+ }
+}
diff --git a/src/components/RoosterEditor/theme/themes.ts b/src/components/RoosterEditor/theme/themes.ts
new file mode 100644
index 0000000..d82e757
--- /dev/null
+++ b/src/components/RoosterEditor/theme/themes.ts
@@ -0,0 +1,59 @@
+import { PartialTheme } from '@fluentui/react/lib/Theme';
+
+const LightTheme: PartialTheme = {
+ palette: {
+ themePrimary: '#cc6688',
+ themeLighterAlt: '#080405',
+ themeLighter: '#211016',
+ themeLight: '#3d1f29',
+ themeTertiary: '#7a3d52',
+ themeSecondary: '#b45a78',
+ themeDarkAlt: '#d17392',
+ themeDark: '#d886a1',
+ themeDarker: '#e2a3b8',
+ neutralLighterAlt: '#f8f8f8',
+ neutralLighter: '#f4f4f4',
+ neutralLight: '#eaeaea',
+ neutralQuaternaryAlt: '#dadada',
+ neutralQuaternary: '#d0d0d0',
+ neutralTertiaryAlt: '#c8c8c8',
+ neutralTertiary: '#595959',
+ neutralSecondary: '#373737',
+ neutralPrimaryAlt: '#2f2f2f',
+ neutralPrimary: '#000000',
+ neutralDark: '#151515',
+ black: '#0b0b0b',
+ white: '#ffffff',
+ },
+};
+
+const DarkTheme: PartialTheme = {
+ palette: {
+ themePrimary: '#cb6587',
+ themeLighterAlt: '#fdf8fa',
+ themeLighter: '#f7e3ea',
+ themeLight: '#f0ccd8',
+ themeTertiary: '#e09db4',
+ themeSecondary: '#d27694',
+ themeDarkAlt: '#b85c7a',
+ themeDark: '#9b4e67',
+ themeDarker: '#72394c',
+ neutralLighterAlt: '#3c3c3c',
+ neutralLighter: '#444444',
+ neutralLight: '#515151',
+ neutralQuaternaryAlt: '#595959',
+ neutralQuaternary: '#5f5f5f',
+ neutralTertiaryAlt: '#7a7a7a',
+ neutralTertiary: '#c8c8c8',
+ neutralSecondary: '#d0d0d0',
+ neutralPrimaryAlt: '#dadada',
+ neutralPrimary: '#ffffff',
+ neutralDark: '#f4f4f4',
+ black: '#f8f8f8',
+ white: '#333333',
+ },
+};
+
+export function getTheme(isDark: boolean): PartialTheme {
+ return isDark ? DarkTheme : LightTheme;
+}
diff --git a/src/components/RoosterEditor/titleBar/TitleBar.scss b/src/components/RoosterEditor/titleBar/TitleBar.scss
new file mode 100644
index 0000000..1e492c9
--- /dev/null
+++ b/src/components/RoosterEditor/titleBar/TitleBar.scss
@@ -0,0 +1,58 @@
+@import '../theme/theme.scss';
+
+.titleBar {
+ display: flex;
+ background-color: $primaryColor;
+ padding: 5px 10px;
+ margin-bottom: 10px;
+ border-radius: 10px;
+ align-items: center;
+}
+
+.title {
+ flex: 0 0 auto;
+ font-size: 24pt;
+ font-family: Arial;
+ font-weight: bold;
+ font-style: italic;
+ color: white;
+ text-shadow: 2px 2px 2px black;
+}
+
+.version {
+ flex: 1 1 auto;
+ color: white;
+ font-family: Calibri;
+ font-size: 14pt;
+ margin: 10px 0 0 10px;
+}
+
+.links {
+ color: white;
+ flex: 0 0 auto;
+ text-align: right;
+ font-size: 14pt;
+ font-family: Calibri;
+}
+
+.link {
+ color: white;
+ text-decoration: none;
+ &:hover {
+ text-decoration: underline;
+ }
+}
+
+.externalLink {
+ vertical-align: middle;
+}
+
+@media (prefers-color-scheme: dark) {
+ .titleBar {
+ background-color: $primaryColorDark;
+ }
+ .title,
+ .link {
+ color: #bbd1e1;
+ }
+}
diff --git a/src/components/RoosterEditor/titleBar/TitleBar.tsx b/src/components/RoosterEditor/titleBar/TitleBar.tsx
new file mode 100644
index 0000000..40076b8
--- /dev/null
+++ b/src/components/RoosterEditor/titleBar/TitleBar.tsx
@@ -0,0 +1,69 @@
+import * as React from 'react';
+
+const styles = require('./TitleBar.scss');
+const github = require('./iconmonstr-github-1.svg');
+
+export interface TitleBarProps {
+ className?: string;
+}
+
+export class TitleBar extends React.Component {
+ render() {
+ const { className: baseClassName } = this.props;
+ const className = styles.titleBar + ' ' + (baseClassName || '');
+ const titleText = 'RoosterJs Demo Site';
+
+ return (
+
+ );
+ }
+}
diff --git a/src/components/RoosterEditor/titleBar/iconmonstr-github-1.svg b/src/components/RoosterEditor/titleBar/iconmonstr-github-1.svg
new file mode 100644
index 0000000..aa05db9
--- /dev/null
+++ b/src/components/RoosterEditor/titleBar/iconmonstr-github-1.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/components/RoosterEditor/utils/cssMonitor.ts b/src/components/RoosterEditor/utils/cssMonitor.ts
new file mode 100644
index 0000000..dcb09c2
--- /dev/null
+++ b/src/components/RoosterEditor/utils/cssMonitor.ts
@@ -0,0 +1,54 @@
+import { Stylesheet } from '@fluentui/merge-styles';
+
+let isCssMonitorStarted: boolean = false;
+const activeWindows: Window[] = [];
+
+function startCssMonitor() {
+ if (!isCssMonitorStarted) {
+ isCssMonitorStarted = true;
+ Stylesheet.getInstance().setConfig({
+ onInsertRule: (cssText: string) => {
+ activeWindows.forEach(win => {
+ const style = win.document.createElement('style');
+ style.textContent = cssText;
+ win.document.head.appendChild(style);
+ });
+ },
+ });
+ }
+}
+
+export function registerWindowForCss(win: Window) {
+ startCssMonitor();
+
+ activeWindows.push(win);
+
+ const styles = document.getElementsByTagName('STYLE');
+ const fragment = win.document.createDocumentFragment();
+
+ for (let i = 0; i < styles.length; i++) {
+ const style = win.document.createElement('style');
+ fragment.appendChild(style);
+
+ const originalStyle = styles[i] as HTMLStyleElement;
+ const rules = originalStyle.sheet.cssRules;
+ let cssText = '';
+
+ for (let j = 0; j < rules.length; j++) {
+ const rule = rules[j] as CSSStyleRule;
+ cssText += rule.cssText;
+ }
+
+ style.textContent = cssText;
+ }
+
+ win.document.head.appendChild(fragment);
+}
+
+export function unregisterWindowForCss(win: Window) {
+ const index = activeWindows.indexOf(win);
+
+ if (index >= 0) {
+ activeWindows.splice(index, 1);
+ }
+}
diff --git a/src/components/RoosterEditor/utils/trustedHTMLHandler.ts b/src/components/RoosterEditor/utils/trustedHTMLHandler.ts
new file mode 100644
index 0000000..2c8b8ce
--- /dev/null
+++ b/src/components/RoosterEditor/utils/trustedHTMLHandler.ts
@@ -0,0 +1,14 @@
+import * as DOMPurify from 'dompurify';
+import { DOMCreator } from 'roosterjs-content-model-types';
+
+export const trustedHTMLHandler: DOMCreator = {
+ htmlToDOM: (html: string) => {
+ return DOMPurify.sanitize(html, {
+ ADD_TAGS: ['head', 'meta', '#comment', 'iframe'],
+ ADD_ATTR: ['name', 'content'],
+ WHOLE_DOCUMENT: true,
+ ALLOW_UNKNOWN_PROTOCOLS: true,
+ RETURN_DOM: true,
+ }).ownerDocument;
+ },
+};
diff --git a/src/views/NewEmail.jsx b/src/views/NewEmail.jsx
index 6c920aa..2b5a53c 100644
--- a/src/views/NewEmail.jsx
+++ b/src/views/NewEmail.jsx
@@ -21,6 +21,7 @@ import useSnippetStore from '@/stores/SnippetStore'
import PaymentlinkBtn from '@/views/Conversations/Online/Input/PaymentlinkBtn'
import { TextIcon } from '@/components/Icons';
import { EMAIL_ATTA_HOST, POPUP_FEATURES } from '@/config';
+import RoosterEditor from '@/components/RoosterEditor';
const {confirm} = Modal;
// 禁止上传的附件类型
@@ -602,30 +603,51 @@ const NewEmail = () => {
requiredMark={false}
// labelCol={{ span: 3 }}
>
-
-
}>
- 发送
-
-
-
- {/*
请选择发件地址
*/}
-
-
{orderDetail.order_no}
-
{templateContent.mailtypeName}
-
setOpenPlainTextConfirm(false)}>
- {/*
+
+
}>
+ 发送
+
+
+
+ {/*
请选择发件地址
*/}
+
+
{orderDetail.order_no}
+
{templateContent.mailtypeName}
+
setOpenPlainTextConfirm(false)}>
+ {/*
纯文本
*/}
- {/* } className=' ' >纯文本 */}
-
-
-
-
+ {/* } className=' ' >纯文本 */}
+
+
+
+
@@ -702,8 +724,9 @@ const NewEmail = () => {
+ Hello from RoosterJs in your Ant Design React app!
' }onChange={handleEditorChange} />
- {!isEmpty(Number(pageParam.quoteid)) && pageParam.action!=='edit' && !showQuoteContent && (
+ {!isEmpty(Number(pageParam.quoteid)) && pageParam.action !== 'edit' && !showQuoteContent && (