Compare commits

...

4 Commits

Author SHA1 Message Date
Lei OT a652f45f5d 1 4 days ago
Lei OT 5ec77f21f2 Merge branch 'main' into dev/RoosterEditor
# Conflicts:
#	src/views/NewEmail.jsx
4 days ago
Lei OT 16378d2d1a 1 4 days ago
Lei OT e82a23d364 . 6 days ago

@ -22,6 +22,8 @@
"react-chat-elements": "^12.0.17", "react-chat-elements": "^12.0.17",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.30.1", "react-router-dom": "^6.30.1",
"roosterjs": "^9.30.0",
"roosterjs-react": "^9.0.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"zustand": "^4.5.7" "zustand": "^4.5.7"

@ -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 = `<!doctype html><html><head><title>RoosterJs Demo Site</title></head><body><div id=${PopoutRoot}></div></body></html>`;
// 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<SidePane>();
protected updateContentPlugin: UpdateContentPlugin;
protected model: ContentModelDocument | null = null;
private knownColors: Record<string, Colors> = {};
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 (
<ThemeProvider applyTo="body" theme={theme} className={'styles.mainPane'}>
{/* {this.renderTitleBar()} */}
{!this.state.popoutWindow && this.renderTabs()}
{!this.state.popoutWindow && this.renderRibbon()}
<div className={''}>
{this.state.popoutWindow ? this.renderPopout() : this.renderEditor()}
</div>
</ThemeProvider>
);
}
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 <TitleBar className={'styles.noGrow'} />;
// }
private renderTabs() {
const tabs = getTabs();
const topRightButtons: RibbonButton<any>[] = [
undoButton,
redoButton,
// zoomButton,
// darkModeButton,
// exportContentButton,
];
// this.state.popoutWindow ? null : topRightButtons.push(popoutButton);
return (
<div
style={{ display: 'inline-flex', justifyContent: 'space-between', height: '30px' }}>
<Ribbon
buttons={tabs}
plugin={this.ribbonPlugin}
dir={this.state.isRtl ? 'rtl' : 'ltr'}></Ribbon>
<Ribbon buttons={topRightButtons} plugin={this.ribbonPlugin}></Ribbon>
</div>
);
}
private renderRibbon() {
return (
<Ribbon
buttons={getButtons(
this.state.activeTab,
this.formatPainterPlugin,
this.imageEditPlugin
)}
plugin={this.ribbonPlugin}
dir={this.state.isRtl ? 'rtl' : 'ltr'}
/>
);
}
// private renderSidePane(fullWidth: boolean) {
// return (
// <SidePane
// ref={this.sidePane}
// plugins={this.getSidePanePlugins()}
// className={`main-pane ${'styles.sidePane'} ${
// fullWidth ? styles.sidePaneFullWidth : ''
// }`}
// />
// );
// }
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 (
<div className={'styles.editorContainer'} id="EditorContainer">
<div style={editorStyles}>
{this.state.editorCreator && (
<Rooster
id={MainPane.editorDivId}
className={'styles.editor'}
plugins={plugins}
defaultSegmentFormat={this.state.initState.defaultFormat}
inDarkMode={this.state.isDarkMode}
getDarkColor={getDarkColor}
snapshots={this.snapshotPlugin.getSnapshots()}
trustedHTMLHandler={trustedHTMLHandler}
initialModel={this.model}
editorCreator={this.state.editorCreator}
dir={this.state.isRtl ? 'rtl' : 'ltr'}
knownColors={this.knownColors}
disableCache={this.state.initState.disableCache}
announcerStringGetter={getAnnouncingString}
experimentalFeatures={Array.from(
this.state.initState.experimentalFeatures
)}
defaultDomToModelOptions={defaultDomToModelOption}
/>
)}
</div>
</div>
);
}
private renderMainPane() {
return (
<>
{this.renderEditor()}
{this.state.showSidePane ? (
<>
<div className={'styles.resizer'} onMouseDown={this.onMouseDown} />
{/* {this.renderSidePane(false)} */}
{/* {this.renderSidePaneButton()} */}
</>
) : (
// this.renderSidePaneButton()
null
)}
</>
);
}
// private renderSidePaneButton() {
// return (
// <button
// className={`side-pane-toggle ${this.state.showSidePane ? 'open' : 'close'} ${
// styles.showSidePane
// }`}
// onClick={this.state.showSidePane ? this.onHideSidePane : this.onShowSidePane}>
// <div>{this.state.showSidePane ? 'Hide side pane' : 'Show side pane'}</div>
// </button>
// );
// }
private renderPopout() {
return (
<>
{/* {this.renderSidePane(true )} */}
{ReactDOM.createPortal(
<WindowProvider window={this.state.popoutWindow}>
<ThemeProvider applyTo="body" theme={getTheme(this.state.isDarkMode)}>
<div className={'styles.mainPane'}>
{this.renderTabs()}
{this.renderRibbon()}
<div className={'styles.body'}>{this.renderEditor()}</div>
</div>
</ThemeProvider>
</WindowProvider>,
this.popoutRoot
)}
</>
);
}
private onMouseDown = (e: React.MouseEvent<EventTarget>) => {
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<KnownAnnounceStrings, string> = {
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(<MainPane />, parent);
}

@ -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 */
}

@ -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;
}

@ -0,0 +1,15 @@
import * as React from 'react';
export interface CodeProps {
code: string;
}
export class Code extends React.Component<CodeProps, {}> {
render() {
return (
<div>
<pre>{this.props.code}</pre>
</div>
);
}
}

@ -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<DefaultFormatProps, {}> {
render() {
return (
<>
<table>
<tbody>
{this.renderFormatItem('fontWeight', 'Bold')}
{this.renderFormatItem('italic', 'Italic')}
{this.renderFormatItem('underline', 'Underline')}
</tbody>
</table>
<table>
<tbody>
{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',
})}
</tbody>
</table>
</>
);
}
private renderFormatItem(id: ToggleFormatId, text: string): JSX.Element {
let checked = !!this.props.state[id];
return (
<tr>
<td className={styles.checkboxColumn}>
<input
type="checkbox"
id={id}
checked={checked}
onChange={() => this.onFormatClick(id)}
/>
</td>
<td>
<div>
<label htmlFor={id}>{text}</label>
</div>
</td>
</tr>
);
}
private renderSelectItem(
id: SelectFormatId,
label: string,
items: { [key: string]: string }
): JSX.Element {
return (
<tr>
<td className={styles.defaultFormatLabel}>{label}</td>
<td>
<select
id={id}
onChange={() => this.onSelectChanged(id)}
defaultValue={(this.props.state[id] || NOT_SET) as string}>
{getObjectKeys(items).map(key => (
<option value={key} key={key}>
{items[key]}
</option>
))}
</select>
</td>
</tr>
);
}
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);
};
}

@ -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<ExperimentalFeature>([
'PersistCache',
'HandleEnterKey',
'CustomCopyCut',
]),
};
export class EditorOptionsPlugin extends SidePanePluginImpl<OptionsPane, OptionPaneProps> {
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,
};
}
}

@ -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<DefaultFormatProps, {}> {
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 (
<div>
<input
type="checkbox"
id={featureName}
checked={checked}
onChange={() => this.onFeatureClick(featureName)}
/>
<label htmlFor={featureName}>{featureName}</label>
</div>
);
}
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);
};
}

@ -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<ExperimentalFeature>;
}
export interface OptionPaneProps extends OptionState, SidePaneElementProps {}
export const UrlPlaceholder = '$url$';

@ -0,0 +1,7 @@
.checkboxColumn {
vertical-align: top;
}
.defaultFormatLabel {
white-space: nowrap;
}

@ -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 =
'<html>\n' +
'<body>\n' +
'<div id="contentDiv" style="width: 800px; height: 400px; border: solid 1px black; overflow: auto"></div>\n';
const htmlButtons =
'<button id=buttonB><b>B</b></button>\n' +
'<button id=buttonI><i>I</i></button>\n' +
'<button id=buttonU><u>U</u></button>\n' +
'<button id=buttonBullet>Bullet</button>\n' +
'<button id=buttonNumbering>Numbering</button>\n' +
'<button id=buttonUndo>Undo</button>\n' +
'<button id=buttonRedo>Redo</button>\n' +
'<button id=buttonTable>Insert Table</button>\n' +
'<button id=buttonDark>Dark mode</button>\n';
'<button id=buttonDark>Dark Mode</button>\n';
const jsCode = '<script src="https://microsoft.github.io/roosterjs/rooster-min.js"></script>\n';
const htmlEnd = '</body>\n' + '</html>';
export class OptionsPane extends React.Component<OptionPaneProps, OptionState> {
private exportForm = React.createRef<HTMLFormElement>();
private exportData = React.createRef<HTMLInputElement>();
private rtl = React.createRef<HTMLInputElement>();
private disableCache = React.createRef<HTMLInputElement>();
constructor(props: OptionPaneProps) {
super(props);
this.state = { ...props };
}
render() {
const editorCode = new EditorCode(this.state);
const html = this.getHtml();
return (
<div>
<details>
<summary>
<b>Default Format</b>
</summary>
<DefaultFormatPane
state={this.state.defaultFormat}
resetState={this.resetState}
/>
</details>
<details>
<summary>
<b>Plugins</b>
</summary>
<Plugins state={this.state} resetState={this.resetState} />
</details>
<details>
<summary>
<b>Experimental features</b>
</summary>
<ExperimentalFeatures state={this.state} resetState={this.resetState} />
</details>
<div>
<br />
</div>
<div>
<input
id="pageRtl"
type="checkbox"
checked={this.state.isRtl}
onChange={this.onToggleDirection}
ref={this.rtl}
/>
<label htmlFor="pageRtl">Show controls from right to left</label>
</div>
<div>
<input
id="disableCache"
type="checkbox"
checked={this.state.disableCache}
onChange={this.onToggleCacheModel}
ref={this.disableCache}
/>
<label htmlFor="disableCache">Disable Content Model Cache</label>
</div>
<hr />
<div>
<button onClick={this.onExportRoosterContentModel}>
Try roosterjs in CodePen
</button>
</div>
<div>
<br />
</div>
<details>
<summary>
<b>HTML Code:</b>
</summary>
<Code code={html} />
</details>
<details>
<summary>
<b>Typescript Code:</b>
</summary>
<Code code={editorCode.getCode()} />
</details>
<form
ref={this.exportForm}
method="POST"
action="https://codepen.io/pen/define"
target="_blank">
<input name="data" type="hidden" ref={this.exportData} />
</form>
</div>
);
}
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}`;
}
}

@ -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<PluginKey extends keyof BuildInPluginList> 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 (
<tr>
<td className={styles.checkboxColumn}>
<input
type="checkbox"
id={id}
checked={checked}
onChange={() => this.onPluginClick(id)}
/>
</td>
<td>
<div>
<label htmlFor={id}>{text}</label>
</div>
{checked && moreOptions}
</td>
</tr>
);
}
protected renderInputBox(
label: string,
ref: React.RefObject<HTMLInputElement>,
value: string,
placeholder: string,
onChange: (state: OptionState, value: string) => void
): JSX.Element {
return (
<div>
{label}
<input
type="text"
ref={ref}
value={value}
placeholder={placeholder}
onChange={() =>
this.props.resetState(state => onChange(state, ref.current.value), false)
}
onBlur={() => this.props.resetState(null, true)}
/>
</div>
);
}
protected renderCheckBox(
label: string,
ref: React.RefObject<HTMLInputElement>,
value: boolean,
onChange: (state: OptionState, value: boolean) => void
): JSX.Element {
return (
<div>
<input
type="checkbox"
ref={ref}
checked={value}
onChange={() =>
this.props.resetState(state => onChange(state, ref.current.checked), true)
}
onBlur={() => this.props.resetState(null, true)}
/>
{label}
</div>
);
}
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<keyof BuildInPluginList> {
private allowExcelNoBorderTable = React.createRef<HTMLInputElement>();
private handleTabKey = React.createRef<HTMLInputElement>();
private handleEnterKey = React.createRef<HTMLInputElement>();
private listMenu = React.createRef<HTMLInputElement>();
private tableMenu = React.createRef<HTMLInputElement>();
private imageMenu = React.createRef<HTMLInputElement>();
private watermarkText = React.createRef<HTMLInputElement>();
private autoBullet = React.createRef<HTMLInputElement>();
private autoNumbering = React.createRef<HTMLInputElement>();
private autoLink = React.createRef<HTMLInputElement>();
private autoUnlink = React.createRef<HTMLInputElement>();
private autoHyphen = React.createRef<HTMLInputElement>();
private autoFraction = React.createRef<HTMLInputElement>();
private autoOrdinals = React.createRef<HTMLInputElement>();
private autoTel = React.createRef<HTMLInputElement>();
private autoMailto = React.createRef<HTMLInputElement>();
private removeListMargins = React.createRef<HTMLInputElement>();
private horizontalLine = React.createRef<HTMLInputElement>();
private markdownBold = React.createRef<HTMLInputElement>();
private markdownItalic = React.createRef<HTMLInputElement>();
private markdownStrikethrough = React.createRef<HTMLInputElement>();
private markdownCode = React.createRef<HTMLInputElement>();
private linkTitle = React.createRef<HTMLInputElement>();
render(): JSX.Element {
return (
<table>
<tbody>
{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')}
</tbody>
</table>
);
}
}

@ -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},
})`;
}
}

@ -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('');
}
}

@ -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('');
}
}

@ -0,0 +1,7 @@
import { CodeElement } from './CodeElement';
export class DarkModeCode extends CodeElement {
getCode() {
return 'roosterjs.getDarkColor';
}
}

@ -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('')) + '}' : '';
}
}

@ -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;
}
}

@ -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}'`;
}
}
}

@ -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)},
})`;
}
}

@ -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(),
]);
}
}

@ -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');
}
}

@ -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)}')`;
}
}

@ -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-)', '😎'),
];

@ -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 (
<div style={{ padding: '20px', maxWidth: '800px', margin: 'auto' }}>
{/* <Title level={2}>RoosterJs Custom Editor with Ant Design</Title> */}
<Space style={{ marginBottom: '16px' }}>
<Button onClick={() => executeCommand('bold')}>Bold</Button>
<Button onClick={() => executeCommand('italic')}>Italic</Button>
<Button onClick={() => executeCommand('underline')}>Underline</Button>
<Button onClick={() => executeCommand('insertUnorderedList')}>Unordered List</Button>
<Button onClick={() => executeCommand('insertOrderedList')}>Ordered List</Button>
</Space>
<div style={{ marginBottom: '16px' }}>
<Radio.Group onChange={handleFormatChange} value={outputFormat}>
<Radio value="html">Show HTML Output</Radio>
<Radio value="text">Show Plain Text Output</Radio>
</Radio.Group>
</div>
<div className="rooster-editor-container">
<div ref={editorDivRef} className="rooster-editor" />
</div>
{/* <Title level={3} style={{ marginTop: '24px' }}>
Current Editor Content (from React state, output as {outputFormat.toUpperCase()}):
</Title>
<Input.TextArea
value={
outputFormat === 'html'
? editorContent
: editorRef.current?.getPlainText(true) || ''
}
autoSize={{ minRows: 5, maxRows: 10 }}
readOnly
style={{
fontFamily: 'monospace',
backgroundColor: '#f0f0f0',
padding: '10px',
borderRadius: '4px',
}}
/>*/}
<Button
type="primary"
style={{ marginTop: '16px' }}
onClick={() => setContentProgrammatically('<h3>This is new content set programmatically from Ant Design Button!</h3><p>It can contain <em><strong>rich text</strong></em> too.</p>')}
>
Set Content Programmatically
</Button>
</div>
);};
export default RoosterEditor;

@ -0,0 +1,8 @@
import { demoUndeletableAnchorParser } from './demoUndeletableAnchorParser';
import { DomToModelOption } from 'roosterjs-content-model-types';
export const defaultDomToModelOption: DomToModelOption = {
additionalFormatParsers: {
link: [demoUndeletableAnchorParser],
},
};

@ -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<UndeletableFormat> = (format, element) => {
if (undeletableLinkChecker(element as HTMLAnchorElement)) {
format.undeletable = true;
}
};

@ -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,
};
}

@ -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<string, HydratedEntity> = {};
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<EntityMetadata>(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<T>(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<T>(element: HTMLElement, metadata: T) {
element.dataset[MetadataDataSetName] = JSON.stringify(metadata);
}

@ -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(
<PickerMenu
x={rect.left}
y={(rect.bottom + rect.top) / 2}
ref={ref => (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<IPickerMenu>
) => {
const [items, setItems] = React.useState<IContextualMenuItem[]>(props.items);
React.useImperativeHandle(ref, () => ({
setMenuItems: setItems,
}));
return (
<Callout target={{ left: props.x, top: props.y }} isBeakVisible={false} gapSpace={10}>
{items.map(item => (
<div className={itemStyle + (item.checked ? ' ' + selectedItemStyle : '')}>
{item.text}
</div>
))}
</Callout>
);
}
);

@ -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);
}
}
}

@ -0,0 +1,22 @@
<svg viewBox="-8 -8 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="32" height="32">
<title>Format painter</title>
<g id="formatpainter16-filled" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="insertion-point" fill="#000000">
<path d="M-3,0 L0,0 L0,1 L-3,1 Z"></path>
<path d="M1,0 L4,0 L4,1 L1,1 Z"></path>
<path d="M0,1 L1,1 L1,15 L0,15 Z"></path>
<path d="M-3,15 L0,15 L0,16 L-3,16 Z"></path>
<path d="M1,15 L4,15 L4,16 L1,16 Z"></path>
</g>
<g id="brush">
<polygon id="Path-2" fill="#FFFFFF" fill-rule="nonzero" points="1.57904078 8.63705201 8.34046001 16.041788 14.56362 10.6509412 15.1653426 8.63705201 13.104499 6.78010591 17.340463 2.01220762 15.8855229 0.661038667 11.3773741 4.92433904 9.4597519 2.96951789"></polygon>
<g id="Group-3" transform="translate(7.000000, 0.000000)">
<path d="M9.4622,2.49777273 C9.5084,2.44813636 9.5359,2.39968182 9.548,2.35004545 C9.5601,2.30040909 9.5656,2.26022727 9.5656,2.2295 C9.5656,2.19995455 9.5601,2.15859091 9.548,2.11013636 C9.5304,2.05459091 9.4996,2.00259091 9.4534,1.95295455 L9.0409,1.50031818 C8.9606,1.42704545 8.8748,1.39040909 8.7835,1.39040909 C8.6801,1.39040909 8.5943,1.42704545 8.525,1.50031818 L4.5815,5.74777273 L2.7335,3.76231818 C2.6642,3.68904545 2.5784,3.65240909 2.475,3.65240909 C2.3782,3.65240909 2.2946,3.68904545 2.2264,3.76231818 L1.5389,4.50095455 L6.798,10.3367727 L7.4602,9.62531818 C7.4712,9.6135 7.4822,9.58631818 7.4943,9.54259091 C7.5064,9.49295455 7.5119,9.42559091 7.5119,9.33931818 C7.5119,9.17268182 7.491,9.03440909 7.4514,8.92331818 C7.4052,8.81340909 7.3744,8.74840909 7.3568,8.7295 L5.5088,6.74522727 L9.4622,2.49777273 Z M10.2355,1.12213636 C10.3785,1.27577273 10.4852,1.45186364 10.5534,1.64804545 C10.6227,1.8395 10.6568,2.03331818 10.6568,2.2295 C10.6568,2.43277273 10.6227,2.63013636 10.5534,2.82040909 C10.4852,3.01186364 10.3785,3.18440909 10.2355,3.33804545 L7.0642,6.74522727 L8.1301,7.88922727 C8.2797,8.04995455 8.3963,8.2615 8.4821,8.52740909 C8.569,8.78504545 8.6119,9.05331818 8.6119,9.32986364 C8.6119,9.56386364 8.58,9.78013636 8.5173,9.97631818 C8.4535,10.1677727 8.3622,10.3273182 8.2423,10.4561364 L6.7892,12.0173182 L-3.73034936e-14,4.48322727 L1.4443,2.92204545 C1.5818,2.77431818 1.7391,2.66440909 1.9173,2.58995455 C2.0944,2.50959091 2.2803,2.47059091 2.475,2.47059091 C2.882,2.47059091 3.2263,2.62068182 3.5068,2.92204545 L4.5815,4.07668182 L7.7517,0.6695 C8.0388,0.362227273 8.3831,0.208590909 8.7835,0.208590909 C8.9782,0.208590909 9.1641,0.247590909 9.3423,0.327954545 C9.5194,0.408318182 9.6767,0.521772727 9.8142,0.6695 L10.2355,1.12213636 Z" id="Fill-1" fill="#666666"></path>
</g>
<g id="Group-6" transform="translate(0.000000, 3.000000)">
<path d="M13.7187692,7.38535 C13.6853077,7.43201667 13.6391538,7.47868333 13.593,7.52535 C13.593,7.52535 13.4649231,7.66535 13.2687692,7.88701667 C13.2122308,7.95701667 13.1545385,8.03868333 13.0841538,8.10868333 C12.8072308,8.41201667 12.4853077,8.75035 12.1045385,9.12368333 C11.8045385,9.43868333 11.4572308,9.75251667 11.1687692,10.0570167 C10.9506923,10.2670167 10.7303077,10.47585 10.4995385,10.6870167 C10.0956923,11.0591833 9.66876923,11.41035 9.288,11.7136833 C9.16107692,11.8186833 9.03415385,11.9120167 8.91876923,11.9936833 C8.538,12.2725167 8.28530769,12.46035 8.28530769,12.46035 L5.35338462,9.06418333 L4.34953846,7.91035 L2.89569231,6.23035 L2.25857859,5.5054604 C2.68550166,5.37712707 3.03280935,5.24879373 3.55088628,5.03879373 C4.07127089,4.82879373 4.61242474,4.57212707 5.15473243,4.2804604 C5.69704012,3.97712707 6.22780935,3.62712707 6.75857859,3.24212707 C7.28934782,2.8454604 7.7739632,2.40212707 8.21242474,1.9004604 L7.38165551,1.0604604 C7.07127089,1.45712707 6.73665551,1.80712707 6.35588628,2.12212707 C5.9739632,2.43712707 5.58280935,2.71712707 5.18934782,2.96212707 C4.7739632,3.20712707 4.35973243,3.42879373 3.95473243,3.6154604 C3.53934782,3.80212707 3.13550166,3.97712707 2.75473243,4.11712707 C1.85473243,4.44379373 1.05857859,4.65379373 0.112424739,4.8054604 L8.07646154,14.00035 C8.56107692,13.6970167 9.04569231,13.37035 9.51876923,13.0075167 C9.993,12.6470167 10.4418462,12.2725167 10.8803077,11.90035 C11.3072308,11.5270167 11.7237692,11.1420167 12.1045385,10.7675167 C12.4853077,10.39535 12.8303077,10.0441833 13.1545385,9.70701667 C13.4649231,9.38035 13.743,9.07701667 13.9737692,8.81918333 C14.2033846,8.55201667 14.388,8.37701667 14.5149231,8.22535 L13.7187692,7.38535 Z" id="Fill-4" fill="#666666"></path>
</g>
<path d="M12.0690848,11.4533915 C11.5246492,12.008121 10.9664883,12.5639943 10.3511389,13.1027111 C9.74951466,13.6585844 9.11929615,14.1664193 8.48907764,14.6285032 L3.43131675,9 L12.0690848,11.4533915 Z" id="Fill-7" fill="#2E77D0"></path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

@ -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;
}
}

@ -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<SnapshotPaneProps, SnapshotPaneState> {
private html = React.createRef<HTMLTextAreaElement>();
private entityStates = React.createRef<HTMLTextAreaElement>();
private isDarkColor = React.createRef<HTMLInputElement>();
private selection = React.createRef<HTMLTextAreaElement>();
private logicalRootPath = React.createRef<HTMLInputElement>();
constructor(props: SnapshotPaneProps) {
super(props);
this.state = {
snapshots: [],
currentIndex: -1,
autoCompleteIndex: -1,
};
}
render() {
return (
<div className={styles.snapshotPane} onPaste={this.onPaste}>
<h3>Undo Snapshots</h3>
<div className={styles.snapshotList}>
{this.state.snapshots.map(this.renderItem)}
</div>
<h3>Selected Snapshot</h3>
<div className={styles.buttons}>
<button onClick={this.takeSnapshot}>{'Take snapshot'}</button>{' '}
<button onClick={this.onClickRestoreSnapshot}>{'Restore snapshot'}</button>
<button onClick={this.onCopy}>{'Copy snapshot with metadata'}</button>
</div>
<div>HTML:</div>
<textarea ref={this.html} className={styles.textarea} spellCheck={false} />
<div>Selection:</div>
<textarea ref={this.selection} className={styles.textarea} spellCheck={false} />
<div>Entity states:</div>
<textarea ref={this.entityStates} className={styles.textarea} spellCheck={false} />
<div>Logical root path:</div>
<input ref={this.logicalRootPath} className={styles.input} spellCheck={false} />
<div>
<input type="checkbox" ref={this.isDarkColor} id="isUndoInDarkColor" />
<label htmlFor="isUndoInDarkColor">Is in dark mode</label>
</div>
</div>
);
}
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 + `<!--${JSON.stringify(metadata)}-->`;
navigator.clipboard.writeText(textToCopy);
};
private onPaste = (event: React.ClipboardEvent) => {
const str = event.clipboardData.getData('text/plain');
if (str) {
const idx = str.lastIndexOf('<!--');
if (idx >= 0 && str.endsWith('-->')) {
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 ? `<!--${JSON.stringify(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 (
<pre
className={className}
key={index}
onClick={() => this.setSnapshot(snapshot)}
onDoubleClick={() => this.props.onMove(index - this.state.currentIndex)}>
{(snapshotStr || '<EMPTY SNAPSHOT>').substring(0, 1000)}
</pre>
);
};
}

@ -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 <SnapshotPane {...this.getComponentProps()} ref={this.refCallback} />;
}
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
);
};
}

@ -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<AllTabStringKeys>[] = [];
TabNames.forEach(tab => {
const tabButton: RibbonButton<AllTabStringKeys> = {
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;
}

@ -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<any>[] = [
boldButton,
italicButton,
underlineButton,
fontButton,
fontSizeButton,
increaseFontSizeButton,
decreaseFontSizeButton,
textColorButton,
backgroundColorButton,
superscriptButton,
subscriptButton,
strikethroughButton,
];
const tableButtons: RibbonButton<any>[] = [
insertTableButton,
formatTableButton,
setTableCellShadeButton,
tableTitleButton,
tableOptionsButton,
tableInsertButton,
tableDeleteButton,
tableBorderApplyButton,
tableBorderColorButton,
tableBorderWidthButton,
tableBorderStyleButton,
tableMergeButton,
tableSplitButton,
tableAlignCellButton,
tableAlignTableButton,
];
const imageButtons: RibbonButton<any>[] = [
insertImageButton,
imageBorderColorButton,
imageBorderWidthButton,
imageBorderStyleButton,
imageBorderRemoveButton,
changeImageButton,
imageBoxShadowButton,
];
const insertButtons: RibbonButton<any>[] = [
insertLinkButton,
removeLinkButton,
insertTableButton,
insertImageButton,
];
const paragraphButtons: RibbonButton<any>[] = [
bulletedListButton,
numberedListButton,
decreaseIndentButton,
increaseIndentButton,
blockQuoteButton,
alignLeftButton,
alignCenterButton,
alignRightButton,
alignJustifyButton,
setHeadingLevelButton,
codeButton,
ltrButton,
rtlButton,
clearFormatButton,
setBulletedListStyleButton,
setNumberedListStyleButton,
listStartNumberButton,
spacingButton,
spaceBeforeButton,
spaceAfterButton,
pasteButton,
];
const allButtons: RibbonButton<any>[] = [
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];
}
}

@ -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;
}
}

@ -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;
}

@ -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;
}
}

@ -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<TitleBarProps, {}> {
render() {
const { className: baseClassName } = this.props;
const className = styles.titleBar + ' ' + (baseClassName || '');
const titleText = 'RoosterJs Demo Site';
return (
<div className={className}>
<div className={styles.title}>
<span className={styles.titleText}>{titleText}</span>
</div>
<div className={styles.version}></div>
<div className={styles.links}>
<a href="./legacyDemo/index.html" className={styles.link}>
Legacy demo
</a>
{' | '}
<a
href="https://github.com/Microsoft/roosterjs/wiki"
target="_blank"
className={styles.link}>
Wiki
</a>
{' | '}
<a href="docs/index.html" target="_blank" className={styles.link}>
References
</a>
{' | '}
<a href="coverage/index.html" target="_blank" className={styles.link}>
Test
</a>
{' | '}
<a
href="https://github.com/microsoft/roosterjs/actions/workflows/build-and-deploy.yml"
target="_blank">
<img
className={styles.externalLink}
src="https://github.com/microsoft/roosterjs/actions/workflows/build-and-deploy.yml/badge.svg"
alt="Build Status"
/>
</a>{' '}
<a href="http://badge.fury.io/js/roosterjs" target="_blank">
<img
src="https://badge.fury.io/js/roosterjs.svg"
alt="NPM Version"
className={styles.externalLink}
/>
</a>{' '}
<a
href="https://github.com/microsoft/roosterjs"
target="_blank"
className={styles.link}
title="RoosterJs on Github">
<img className={styles.externalLink} src={github} />
</a>
</div>
</div>
);
}
}

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>

After

Width:  |  Height:  |  Size: 814 B

@ -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);
}
}

@ -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;
},
};

@ -31,7 +31,7 @@ import useAuthStore from '@/stores/AuthStore'
import '@/assets/index.css' import '@/assets/index.css'
import CustomerRelation from '@/views/customer_relation/index' import CustomerRelation from '@/views/customer_relation/index'
import NewEmail from '@/views/NewEmail' import NewEmail from '@/views/NewEmail1'
import EmailDetailWindow from '@/views/EmailDetailWindow' import EmailDetailWindow from '@/views/EmailDetailWindow'
import { executeDailyCleanupTask, setupDailyMidnightCleanupScheduler } from '@/utils/indexedDB' import { executeDailyCleanupTask, setupDailyMidnightCleanupScheduler } from '@/utils/indexedDB'

@ -0,0 +1,750 @@
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
import { useParams, useNavigate, useLocation } from 'react-router-dom';
import { App, ConfigProvider, Button, Form, Input, Flex, Checkbox, Popconfirm, Select, Space, Upload, Divider, Modal, Tabs, Radio, Typography, } from 'antd'
import { UploadOutlined, LoadingOutlined, SaveOutlined, SendOutlined, CheckCircleOutlined, ExclamationCircleFilled } from '@ant-design/icons'
import useStyleStore from '@/stores/StyleStore'
// import useConversationStore from '@/stores/ConversationStore'
import useAuthStore from '@/stores/AuthStore'
import LexicalEditor from '@/components/LexicalEditor'
import { v4 as uuid } from 'uuid'
import { cloneDeep, debounce, isEmpty, olog, omitEmpty } from '@/utils/commons'
import { writeIndexDB, readIndexDB, deleteIndexDBbyKey, } from '@/utils/indexedDB';
import '@/views/Conversations/Online/Input/EmailEditor.css'
import { deleteEmailAttachmentAction, parseHTMLString, postSendEmail, saveEmailDraftOrSendAction } from '@/actions/EmailActions'
import { sentMsgTypeMapped } from '@/channel/bubbleMsgUtils'
import { EmailBuilder, openPopup, useEmailDetail, useEmailSignature, useEmailTemplate } from '@/hooks/useEmail'
import useSnippetStore from '@/stores/SnippetStore'
// import { useOrderStore } from '@/stores/OrderStore'
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;
//
// .application, .exe, .app
const disallowedAttachmentTypes = [
'.ps1',
'.msi',
'application/x-msdownload',
'application/x-ms-dos-executable',
'application/x-ms-wmd',
'application/x-ms-wmz',
'application/x-ms-xbap',
'application/x-msaccess',
]
const getAbstract = (longtext) => {
const lines = longtext.split('\n')
const firstLine = lines[0]
const abstract = firstLine.substring(0, 20)
return abstract
}
const parseHTMLText = (html) => {
const parser = new DOMParser()
const dom = parser.parseFromString(html, 'text/html')
// Replace <br> and <p> with line breaks
Array.from(dom.body.querySelectorAll('br, p')).forEach((el) => {
el.textContent = '\n' + el.textContent
})
// Replace <hr> with a line of dashes
Array.from(dom.body.querySelectorAll('hr')).forEach((el) => {
el.textContent = '\n------------------------------------------------------------------\n'
})
return dom.body.textContent || ''
}
const generateQuoteContent = (mailData, isRichText = true) => {
const html = `<br><br><hr><p class="font-sans"><b><strong >From: </strong></b><span >${(mailData.info?.MAI_From || '')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')} </span></p><p class="font-sans"><b><strong >Sent: </strong></b><span >${
mailData.info?.MAI_SendDate || ''
}</span></p><p class="font-sans"><b><strong >To: </strong></b><span >${(mailData.info?.MAI_To || '')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')}</span></p><p class="font-sans"><b><strong >Subject: </strong></b><span >${mailData.info?.MAI_Subject || ''}</span></p><p>${
mailData.info?.MAI_ContentType === 'text/html' ? mailData.content : mailData.content.replace(/\r\n/g, '<br>')
}</p>`
return isRichText ? html : parseHTMLText(html)
}
const generateMailContent = (mailData) => `${mailData.content}<br>`
/**
* 独立窗口编辑器
*
* - 从销售平台进入: 自动复制 storage, 可读取loginUser
*
* ! 无状态管理
*/
const NewEmail = () => {
const pageParam = useParams();
const { templateKey } = pageParam
const editorKey = pageParam.action==='new' ? `new-0-${pageParam.oid}` : `${pageParam.action}-${pageParam.quoteid}`
const { notification, message } = App.useApp()
const [form] = Form.useForm()
const [mobile] = useStyleStore((state) => [state.mobile])
const [userId, username, emailList] = useAuthStore((state) => [state.loginUser.userId, state.loginUser.username, state.loginUser.emailList])
const emailListOption = useMemo(() => emailList?.map((ele) => ({ ...ele, label: ele.email, key: ele.email, value: ele.email })) || [], [emailList])
const emailListOPIMapped = useMemo(() => emailListOption?.reduce((r, v) => ({ ...r, [v.opi_sn]: v }), {}), [emailListOption]);
const emailListAddrMapped = useMemo(() => emailListOption?.reduce((r, v) => ({ ...r, [v.email]: v }), {}), [emailListOption])
const emailListMatMapped = useMemo(() => emailListOption?.reduce((r, v) => ({ ...r, [v.mat_sn]: v }), {}), [emailListOption])
// console.log('emailListMapped', emailListOption, emailListAddrMapped);
const [emailAccount, setEmailAccount] = useState({});
const [emailAccountOPI, setEmailAccountOPI] = useState(0);
const mai_sn = pageParam.quoteid // activeEdit.quoteid
const { loading: quoteLoading, mailData, orderDetail, postEmailSaveOrSend } = useEmailDetail(mai_sn, null, pageParam.oid)
const { loading: loadingTamplate, templateContent } = useEmailTemplate(templateKey, {coli_sn: pageParam.oid, opi_sn: orderDetail.opi_sn || mailData.info?.MAI_OPI_SN || 0, lgc: 1});
const initOPI = useMemo(() => emailAccountOPI || orderDetail.opi_sn || mailData.info?.MAI_OPI_SN || 0, [emailAccountOPI, mailData, orderDetail])
const { signature } = useEmailSignature(initOPI)
const [initialContent, setInitialContent] = useState('')
const [showQuoteContent, setShowQuoteContent] = useState(false)
const [quoteContent, setQuoteContent] = useState('')
// const [newFromEmail, setNewFromEmail] = useState('')
// const [newToEmail, setNewToEmail] = useState('')
// const [emailOPI, setEmailOPI] = useState('')
// const [emailOrder, setEmailOrder] = useState('')
// const [emailOrderSN, setEmailOrderSN] = useState('')
// const [emailMat, setEmailMat] = useState('')
// const [contentPrefix, setContentPrefix] = useState('')
const [localDraft, setLocalDraft] = useState();
// const readMailboxLocalCache = async () => {
// console.log('===============', 'readMailboxLocalCache')
// const readCache = await readIndexDB(editorKey, 'draft', 'mailbox')
// if (readCache) {
// const btn = (
// <Space>
// <Button type='link' size='small' onClick={() => notification.destroy()}>
//
// </Button>
// {/* <Button type="primary" size="small" onClick={() => notification.destroy()}>
// Confirm
// </Button> */}
// </Space>
// )
// // notification.open({
// // key: editorKey,
// // placement: 'top',
// // // message: '',
// // description: '',
// // duration: 0,
// // actions: btn,
// // })
// setLocalDraft(readCache)
// if (!isEmpty(localDraft)) {
// const { htmlContent, ...draftFormsValues } = localDraft
// const _findMatOld = emailListMatMapped?.[draftFormsValues.mat_sn]
// const _from = draftFormsValues?.from || _findMatOld?.email || ''
// form.setFieldsValue(draftFormsValues)
// setNewFromEmail(_from)
// setEmailOPI(draftFormsValues.opi_sn)
// setEmailMat(draftFormsValues.mat_sn)
// setEmailOrder(draftFormsValues.coli_sn)
// requestAnimationFrame(() => {
// setInitialContent(htmlContent)
// })
// return false
// }
// }
// }
// useEffect(() => {
// readMailboxLocalCache()
// return () => {}
// }, [])
//
// -
// -
useEffect(() => {
// console.log('useEffect 1---- \nform.setFieldsValue ');
if (isEmpty(mailData.content) && isEmpty(orderDetail.order_no)) {
// return () => {}
}
const docTitle = mailData.info?.MAI_Subject || 'New Email-';
document.title = docTitle
const { order_no } = orderDetail
// setContentPrefix(order_no ? `<p>Dear Mr./Ms. ${orderDetail.contact?.[0]?.name || ''}</p><p>Reference Number: ${order_no}</p>` : '')
const orderPrefix = order_no ? `<p>Dear Mr./Ms. ${orderDetail.contact?.[0]?.name || ''}</p><p>Reference Number: ${order_no}</p>` : ''
const { info } = mailData
const { ...templateFormValues } = templateContent;
const orderReceiver = orderDetail.contact?.[0]?.email || ''
const _findMatOld = emailListOPIMapped?.[orderDetail.opi_sn]
const orderSender = _findMatOld?.email || ''
const _findMatOldE = emailListMatMapped?.[info.MAI_MAT_SN]
const quotedMailSender = _findMatOldE?.email || ''
const sender = quotedMailSender || orderSender
const quotedMailSenderObj = emailAccount?.email || sender; // { key: sender, label: sender, value: sender }
const defaultMAT = emailListAddrMapped?.[sender]?.mat_sn || ''
const _form2 = {
coli_sn: Number(pageParam.oid) || info?.MAI_COLI_SN || '',
mat_sn: emailAccount?.mat_sn || info?.MAI_MAT_SN || defaultMAT,
opi_sn: emailAccount?.opi_sn || info?.MAI_OPI_SN || orderDetail.opi_sn || '',
}
let readyToInitialContent = '';
let _formValues = {};
// setShowCc(!isEmpty(mailData.info?.MAI_CS));
const signatureBody = generateMailContent({ content: signature })
// const preQuoteBody = generateQuoteContent(mailData)
// const _initialContent = isEmpty(mailData.info) ? signatureBody : signatureBody+preQuoteBody
// 稿: ``id
if (!isEmpty(mailData.info) && !['edit'].includes(pageParam.action)) {
readyToInitialContent = orderPrefix + signatureBody
}
switch (pageParam.action) {
case 'reply':
_formValues = {
from: quotedMailSenderObj,
to: info?.replyTo || orderReceiver,
cc: info?.MAI_CS || '',
// bcc: quote.bcc || '',
subject: `Re: ${info.MAI_Subject || ''}`,
..._form2
}
break
case 'replyall':
_formValues = {
from: quotedMailSenderObj,
to: info?.replyToAll || orderReceiver,
cc: info?.MAI_CS || '',
// bcc: quote.bcc || '',
subject: `Re: ${info.MAI_Subject || ''}`,
..._form2
}
break
case 'forward':
_formValues = {
from: quotedMailSenderObj,
subject: `Fw: ${info.MAI_Subject || ''}`,
// coli_sn: pageParam.oid,
..._form2
}
break
case 'edit':
_formValues = {
from: quotedMailSenderObj,
to: info?.MAI_To || '',
cc: info?.MAI_CS || '',
subject: `${info.MAI_Subject || ''}`,
id: pageParam.quoteid,
mai_sn: pageParam.quoteid,
..._form2
}
readyToInitialContent = '<br>'+ generateMailContent(mailData)
setFileList(mailData.attachments.map(ele => ({ uid: ele.ATI_SN, name: ele.ATI_Name, url: ele.ATI_ServerFile, fullPath: `${EMAIL_ATTA_HOST}${ele.ATI_ServerFile}` })))
break
case 'new':
_formValues = {
...templateFormValues,
from: quotedMailSenderObj,
to: orderReceiver || info?.MAI_To || '',
subject: `${info.MAI_Subject || templateFormValues.subject || ''}`,
..._form2,
}
readyToInitialContent = generateMailContent({ content: templateContent.bodycontent || readyToInitialContent || `<br>${signatureBody}` || '' })
setFileList(mailData.attachments.map(ele => ({ uid: ele.ATI_SN, name: ele.ATI_Name, url: ele.ATI_ServerFile, fullPath: `${EMAIL_ATTA_HOST}${ele.ATI_ServerFile}` })))
break
default:
break
}
// olog('222', _formValues, pageParam.action)
form.setFieldsValue(_formValues) // todo: from
setInitialContent(readyToInitialContent);
return () => {}
}, [orderDetail.order_no, quoteLoading, loadingTamplate, emailAccount, signature])
// const readFromTemplate = () => {
// const { mailcontent, ...templateFormValues } = templateContent;
// if (mailcontent) {
// const _findMatOld = emailListOPIMapped?.[orderDetail.opi_sn]
// const _from = _findMatOld?.email || ''
// form.setFieldsValue({...templateFormValues, to: orderDetail?.contact?.[0]?.email || '', from1: { key: _from, value: _from, label: _from}});
// // setNewFromEmail(_from);
// // setEmailOPI(draftFormsValues.opi_sn)
// requestAnimationFrame(() => {
// setInitialContent(mailcontent);
// });
// }
// }
useEffect(() => {
// readMailboxLocalCache()
if (loadingTamplate) {
notification.open({
key: editorKey,
placement: 'top',
// message: '',
description: '正在加载邮件模板...',
duration: 0,
icon: <LoadingOutlined />,
// actions: btn,
// closeIcon: null,
closable: false,
})
} else {
notification.destroy(editorKey)
}
// readFromTemplate();
return () => {}
}, [loadingTamplate])
const handleSwitchEmail = (value) => {
// const { value } = labelValue
// setNewFromEmail(value)
const _findMat = emailListAddrMapped?.[value]
form.setFieldsValue({ mat_sn: _findMat?.mat_sn, opi_sn: _findMat?.opi_sn })
// console.log(_findMat, 'handleSwitchEmail')
setEmailAccount(_findMat)
setEmailAccountOPI(_findMat?.opi_sn)
}
const [isRichText, setIsRichText] = useState(mobile === false)
// const [isRichText, setIsRichText] = useState(false); //
const [htmlContent, setHtmlContent] = useState('')
const [textContent, setTextContent] = useState('')
const [showCc, setShowCc] = useState(true)
const [showBcc, setShowBcc] = useState(false)
const handleShowCc = () => {
setShowCc(true)
}
const handleShowBcc = () => {
setShowBcc(true)
}
const handleEditorChange = ({ editorStateJSON, htmlContent, textContent }) => {
// console.log('textContent', textContent);
// console.log('html', html);
setHtmlContent(htmlContent)
setTextContent(textContent)
form.setFieldValue('content', htmlContent)
const { bodyText: abstract } = parseHTMLString(htmlContent, true);
// form.setFieldValue('abstract', getAbstract(textContent))
const formValues = omitEmpty(form.getFieldsValue());
if (!isEmpty(formValues)) {
debouncedSave({ ...form.getFieldsValue(), htmlContent, abstract, })
}
}
const [openPlainTextConfirm, setOpenPlainTextConfirm] = useState(false)
const handlePlainTextOpenChange = ({ target }) => {
const { value: newChecked } = target
if (newChecked === true) {
setIsRichText(true)
setOpenPlainTextConfirm(false)
return
}
setOpenPlainTextConfirm(true)
}
const confirmPlainText = () => {
setIsRichText(false)
setOpenPlainTextConfirm(false)
}
// :
// 1. ~~
// 2.
const [fileList, setFileList] = useState([])
// const handleChange = (info) => {
// let newFileList = [...info.fileList]
// // 2. Read from response and show file link
// newFileList = newFileList.map((file) => {
// if (file.response) {
// // Component will show file.url as link
// file.url = file.response.url
// }
// return file
// })
// setFileList(newFileList)
// }
const normFile = (e) => {
// console.log('Upload event:', e);
if (Array.isArray(e)) {
return e
}
return e?.fileList
}
const uploadProps = {
// action: 'https://660d2bd96ddfa2943b33731c.mockapi.io/api/upload',
// onChange: handleChange,
multiple: true,
fileList,
beforeUpload: (file) => {
// console.log('beforeUpload', file);
const lastDotIndex = file.name.lastIndexOf('.')
const extension = file.name.slice(lastDotIndex).toLocaleLowerCase()
if (disallowedAttachmentTypes.includes(file.type) || disallowedAttachmentTypes.includes(extension)) {
message.warning('不支持的文件格式: ' + extension)
return false
}
setFileList((prev) => [...prev, file])
return false // ,
},
onRemove: async (file) => {
console.log('onRomove', file)
if (file.fullPath) {
try {
const x = await deleteEmailAttachmentAction([file.uid]);
message.success(`已删除 ${file.name}`, 2)
} catch (error) {
console.error(error)
notification.error({
key: editorKey,
message: '删除失败',
description: error.message,
placement: 'top',
duration: 3,
})
return false;
}
}
const index = fileList.indexOf(file)
const newFileList = fileList.slice()
newFileList.splice(index, 1)
setFileList(newFileList)
},
onPreview: (file) => {
// console.log('pn preview', file);
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onloadend = (e) => {
if (file.size > 1.5 * 1024 * 1024) {
message.info('附件太大,无法预览')
// message.info(', ')
// var downloadLink = document.createElement('a');
// downloadLink.href = e.target.result;
// downloadLink.download = file.name;
// downloadLink.click();
resolve(e.target.result)
return
}
var win = window.open('', file.uid, POPUP_FEATURES)
win.document.body.style.margin = '0'
if (file.type.startsWith('image/')) {
win.document.write("<img src='" + e.target.result + '\' style="max-width: 100%;" />')
} else if (file.type.startsWith('text/') || file.type === 'application/html' || file.type === 'application/xhtml+xml') {
var iframe = win.document.createElement('iframe')
iframe.srcdoc = e.target.result
iframe.style.width = '100%'
iframe.style.height = '100%'
iframe.style.border = 'none'
win.document.body.appendChild(iframe)
win.document.body.style.margin = '0'
} else if (file.type === 'application/pdf') {
// win.document.write("<iframe src='" + e.target.result + "' width='100%' height='100%' frameborder=\"0\"></iframe>");
win.document.write("<embed src='" + e.target.result + "' width='100%' height='100%' style=\"border:none\"></embed>")
win.document.body.style.margin = '0'
} else if (file.type.startsWith('audio/')) {
win.document.write("<audio controls src='" + e.target.result + '\' style="max-width: 100%;"></audio>')
} else if (file.type.startsWith('video/')) {
win.document.write("<video controls src='" + e.target.result + '\' style="max-width: 100%;"></video>')
} else {
win.document.write('<h2>Preview not available for this file type</h2>')
}
// win.document.write("<iframe src='" + dataURL + "' width='100%' height='100%' style=\"border:none\"></iframe>");
resolve(reader.result)
}
if (file.fullPath) {
openPopup(file.fullPath, file.uid)
}
else if (file.type.startsWith('text/') || file.type === 'application/html' || file.type === 'application/xhtml+xml') {
reader.readAsText(file)
} else {
reader.readAsDataURL(file)
}
// reader.readAsDataURL(file);
reader.onerror = (error) => reject(error)
})
},
}
const [sendLoading, setSendLoading] = useState(false)
const onHandleSaveOrSend = async (isDraft = false) => {
// console.log('onSend callback', '\nisRichText', isRichText);
// console.log(form.getFieldsValue());
const body = structuredClone(form.getFieldsValue())
body.attaList = fileList;
// console.log('body', body, '\n', fileList);
const values = await form.validateFields()
const preQuoteBody = !['edit', 'new'].includes(pageParam.action) && pageParam.quoteid ? (quoteContent ? quoteContent : generateQuoteContent(mailData, isRichText)) : ''
body.mailcontent = isRichText ? EmailBuilder({ subject: values.subject, content: htmlContent + preQuoteBody }) : textContent + preQuoteBody
body.cc = values.cc || ''
body.bcc = values.bcc || ''
body.bcc = values.mailtype || ''
setSendLoading(!isDraft)
notification.open({
key: editorKey,
placement: 'top',
// message: '',
description: '正在保存...',
duration: 0,
icon: <LoadingOutlined className='text-primary' />,
closable: false,
})
// body.externalID = stickToCid
// body.actionID = `${stickToCid}.${msgObj.id}`
body.contenttype = isRichText ? 'text/html' : 'text/plain'
try {
// console.log('postSendEmail', body, '\n');
// return;
const mailSavedId = await postEmailSaveOrSend(body, isDraft)
form.setFieldsValue({
mai_sn: mailSavedId,
id: mailSavedId,
})
// bubbleMsg.email.mai_sn = mailSavedId
// setSendLoading(false);
if (!isDraft) {
notification.success({
key: editorKey,
message: '成功',
description: isDraft ? '' : '窗口将自动关闭...',
placement: 'top',
duration: 2,
showProgress: true,
pauseOnHover: true,
onClose: () => {
deleteIndexDBbyKey(editorKey, 'draft', 'mailbox');
isDraft ? false : window.close();
},
})
} else { notification.destroy(editorKey) }
// setOpen(false)
} catch (error) {
console.error(error)
notification.error({
key: editorKey,
message: '邮件保存失败',
description: error.message,
placement: 'top',
duration: 3,
})
} finally {
setSendLoading(false)
}
}
const [openDrawerSnippet] = useSnippetStore((state) => [state.openDrawer])
const idleCallbackId = useRef(null)
const debouncedSave = useCallback(
debounce((data) => {
idleCallbackId.current = window.requestIdleCallback(() => {
console.log('Saving data (idle, debounced):', data)
writeIndexDB([{ ...data, key: editorKey }], 'draft', 'mailbox')
})
}, 1500), // 1.5s
[],
)
useEffect(() => {
return () => {
if (idleCallbackId.current && window.cancelIdleCallback) {
window.cancelIdleCallback(idleCallbackId.current)
}
}
}, [debouncedSave])
const onEditChange = (changedValues, allValues) => {
// console.log('onEditChange', changedValues, '\n', allValues)
if ('from' in changedValues) {
handleSwitchEmail(allValues.from);
}
}
return (
<>
<ConfigProvider theme={{ token: { colorPrimary: '#6366f1' } }}>
<Form
form={form}
onValuesChange={onEditChange}
// onFinishFailed={onFinishFailed}
preserve={false}
name={`email_max_form`}
size='small'
layout={'inline'}
variant={'borderless'}
// initialValues={{}}
// onFinish={() => {}}
className='email-editor-wrapper *:mb-2 *:border-b *:border-t-0 *:border-x-0 *:border-indigo-100 *:border-solid '
requiredMark={false}
// labelCol={{ span: 3 }}
>
<div className='w-full flex flex-wrap gap-2 justify-start items-center text-indigo-600 pb-1 mb-2 border-x-0 border-t-0 border-b border-solid border-neutral-200'>
<Button type='primary' size='middle' onClick={onHandleSaveOrSend} loading={sendLoading} icon={<SendOutlined />}>
发送
</Button>
<Form.Item name={'from'} rules={[{ required: true, message: '请选择发件地址' }]}>
<Select
labelInValue={false}
options={emailListOption}
labelRender={(item) => `发件人: ${item?.label || '选择'}`}
variant={'borderless'}
placeholder='发件人: 选择'
className='[&_.ant-select-selection-item]:font-bold [&_.ant-select-selection-placeholder]:font-bold [&_.ant-select-selection-placeholder]:text-black'
classNames={{ popup: { root: 'min-w-60' } }}
/>
</Form.Item>
{/* <div className="ant-form-item-explain-error text-red-500" >请选择发件地址</div> */}
<div className='ml-auto'></div>
<span>{orderDetail.order_no}</span>
<span>{templateContent.mailtypeName}</span>
<Popconfirm
trigger1={['hover', 'click']}
description='切换内容为纯文本格式将丢失信件和签名的格式, 确定使用纯文本?'
onConfirm={confirmPlainText}
open={openPlainTextConfirm}
onCancel={() => setOpenPlainTextConfirm(false)}>
{/* <Checkbox checked={!isRichText} onChange={handlePlainTextOpenChange}>
纯文本
</Checkbox> */}
{/* <Button type='link' size='small' icon={<TextIcon />} className=' ' >纯文本</Button> */}
<Radio.Group
options={[
{ label: '纯文本', value: false },
{ label: '富文本', value: true },
]}
optionType='button'
buttonStyle='solid'
onChange={handlePlainTextOpenChange}
value={isRichText}
size='small'
/>
</Popconfirm>
<Button onClick={() => onHandleSaveOrSend(true)} type='dashed' icon={<SaveOutlined />} size='small' className=''>
存草稿
</Button>
</div>
<Form.Item className='w-full'>
<Space.Compact className='w-full'>
<Form.Item name={'to'} label='收件人' rules={[{ required: true }]} className='!flex-1'>
<Input className='w-full' />
</Form.Item>
<Flex gap={4}>
{!showCc && (
<Button type='text' onClick={handleShowCc}>
抄送
</Button>
)}
{!showBcc && (
<Button type='text' hidden={showBcc} onClick={handleShowBcc}>
密送
</Button>
)}
</Flex>
</Space.Compact>
</Form.Item>
<Form.Item label='抄&nbsp;&nbsp;&nbsp;&nbsp;送' name={'cc'} hidden={!showCc} className='w-full pt-1'>
<Input />
</Form.Item>
<Form.Item label='密&nbsp;&nbsp;&nbsp;&nbsp;送' name={'bcc'} hidden={!showBcc} className='w-full pt-1'>
<Input />
</Form.Item>
<Form.Item label='主&nbsp;&nbsp;&nbsp;&nbsp;题' name={'subject'} rules={[{ required: true }]} className='w-full pt-1'>
<Input />
</Form.Item>
<Form.Item name='atta' label='' className='w-full py-1 border-b-0' valuePropName='fileList' getValueFromEvent={normFile}>
<Flex justify='space-between'>
<Upload {...uploadProps} name='file' className='w-full [&_.ant-upload-list-item-name]:cursor-pointer'>
<Button icon={<UploadOutlined />}>附件</Button>
</Upload>
<Flex align={'center'} className='absolute right-0'>
<Divider type='vertical' />
<Button type={'link'} onClick={() => openDrawerSnippet()}>
图文集
</Button>
<PaymentlinkBtn type={'link'} />
{/* 更多工具 */}
{/* <Popover
content={
<div className='flex flex-col gap-2'>
<Button type={'link'}>??</Button>
</div>
}
trigger='click'
><MoreOutlined /></Popover> */}
</Flex>
</Flex>
</Form.Item>
<Form.Item name='content' hidden>
<Input />
</Form.Item>
<Form.Item name='abstract' hidden>
<Input />
</Form.Item>
<Form.Item name='id' hidden>
<Input />
</Form.Item>
<Form.Item name='mai_sn' hidden>
<Input />
</Form.Item>
<Form.Item name='mat_sn' hidden>
<Input />
</Form.Item>
<Form.Item name='coli_sn' hidden>
<Input />
</Form.Item>
<Form.Item name='opi_sn' hidden>
<Input />
</Form.Item>
<Form.Item name='mailtype' hidden>
<Input />
</Form.Item>
</Form>
<RoosterEditor initialContent={'<p>Hello from <b>RoosterJs</b> in your <i>Ant Design</i> React app!</p>' }onChange={handleEditorChange} />
<LexicalEditor {...{ isRichText }} onChange={handleEditorChange} defaultValue={initialContent} />
{!isEmpty(Number(pageParam.quoteid)) && pageParam.action !== 'edit' && !showQuoteContent && (
<div className='flex justify-start items-center ml-2'>
<Button className='flex gap-2 ' type='link' onClick={() => setShowQuoteContent(!showQuoteContent)}>
显示引用内容 {/*(不可更改)*/}
</Button>
{/* <Button className='flex gap-2 ' type='link' danger onClick={() => {setMergeQuote(false);setShowQuoteContent(false)}}>
删除引用内容
</Button> */}
</div>
)}
{showQuoteContent && (
<blockquote
// contentEditable
className='border-0 outline-none cursor-text'
onBlur={(e) => setQuoteContent(`<blockquote>${e.target.innerHTML}</blockquote>`)}
dangerouslySetInnerHTML={{ __html: generateQuoteContent(mailData) }}></blockquote>
)}
</ConfigProvider>
</>
)
}
export default NewEmail
Loading…
Cancel
Save