From e82a23d364793e38edf8fbb0942968caeffe0261 Mon Sep 17 00:00:00 2001 From: Lei OT Date: Tue, 24 Jun 2025 13:49:09 +0800 Subject: [PATCH] . --- package.json | 2 + src/components/RoosterEditor/MainPane.tsx | 564 ++++++++++++++++++ .../RoosterEditor/RoosterEditor.css | 25 + .../RoosterEditor/SidePanePlugin.ts | 7 + .../RoosterEditor/editorOptions/Code.tsx | 15 + .../editorOptions/DefaultFormatPane.tsx | 146 +++++ .../editorOptions/EditorOptionsPlugin.ts | 88 +++ .../editorOptions/ExperimentalFeatures.tsx | 48 ++ .../editorOptions/OptionState.ts | 56 ++ .../editorOptions/OptionsPane.scss | 7 + .../editorOptions/OptionsPane.tsx | 187 ++++++ .../RoosterEditor/editorOptions/Plugins.tsx | 323 ++++++++++ .../editorOptions/codes/AutoFormatCode.ts | 20 + .../editorOptions/codes/ButtonsCode.ts | 26 + .../editorOptions/codes/CodeElement.ts | 14 + .../editorOptions/codes/DarkModeCode.ts | 7 + .../editorOptions/codes/DefaultFormatCode.ts | 31 + .../editorOptions/codes/EditorCode.ts | 40 ++ .../editorOptions/codes/HyperLinkCode.ts | 32 + .../editorOptions/codes/MarkdownCode.ts | 17 + .../editorOptions/codes/PluginsCode.ts | 50 ++ .../editorOptions/codes/SimplePluginCode.ts | 41 ++ .../editorOptions/codes/WatermarkCode.ts | 11 + .../editorOptions/getReplacements.ts | 37 ++ src/components/RoosterEditor/index.jsx | 117 ++++ .../options/defaultDomToModelOption.ts | 8 + .../options/demoUndeletableAnchorParser.ts | 13 + .../plugins/FormatPainterPlugin.ts | 91 +++ .../plugins/SampleEntityPlugin.ts | 156 +++++ .../plugins/SamplePickerPlugin.tsx | 206 +++++++ .../plugins/UpdateContentPlugin.ts | 60 ++ .../plugins/formatpaintercursor.svg | 22 + .../RoosterEditor/snapshot/SnapshotPane.scss | 61 ++ .../RoosterEditor/snapshot/SnapshotPane.tsx | 188 ++++++ .../RoosterEditor/snapshot/SnapshotPlugin.tsx | 85 +++ src/components/RoosterEditor/tabs/getTabs.ts | 61 ++ .../RoosterEditor/tabs/ribbonButtons.ts | 222 +++++++ src/components/RoosterEditor/theme/theme.scss | 27 + src/components/RoosterEditor/theme/themes.ts | 59 ++ .../RoosterEditor/titleBar/TitleBar.scss | 58 ++ .../RoosterEditor/titleBar/TitleBar.tsx | 69 +++ .../titleBar/iconmonstr-github-1.svg | 1 + .../RoosterEditor/utils/cssMonitor.ts | 54 ++ .../RoosterEditor/utils/trustedHTMLHandler.ts | 14 + src/views/NewEmail.jsx | 69 ++- 45 files changed, 3412 insertions(+), 23 deletions(-) create mode 100644 src/components/RoosterEditor/MainPane.tsx create mode 100644 src/components/RoosterEditor/RoosterEditor.css create mode 100644 src/components/RoosterEditor/SidePanePlugin.ts create mode 100644 src/components/RoosterEditor/editorOptions/Code.tsx create mode 100644 src/components/RoosterEditor/editorOptions/DefaultFormatPane.tsx create mode 100644 src/components/RoosterEditor/editorOptions/EditorOptionsPlugin.ts create mode 100644 src/components/RoosterEditor/editorOptions/ExperimentalFeatures.tsx create mode 100644 src/components/RoosterEditor/editorOptions/OptionState.ts create mode 100644 src/components/RoosterEditor/editorOptions/OptionsPane.scss create mode 100644 src/components/RoosterEditor/editorOptions/OptionsPane.tsx create mode 100644 src/components/RoosterEditor/editorOptions/Plugins.tsx create mode 100644 src/components/RoosterEditor/editorOptions/codes/AutoFormatCode.ts create mode 100644 src/components/RoosterEditor/editorOptions/codes/ButtonsCode.ts create mode 100644 src/components/RoosterEditor/editorOptions/codes/CodeElement.ts create mode 100644 src/components/RoosterEditor/editorOptions/codes/DarkModeCode.ts create mode 100644 src/components/RoosterEditor/editorOptions/codes/DefaultFormatCode.ts create mode 100644 src/components/RoosterEditor/editorOptions/codes/EditorCode.ts create mode 100644 src/components/RoosterEditor/editorOptions/codes/HyperLinkCode.ts create mode 100644 src/components/RoosterEditor/editorOptions/codes/MarkdownCode.ts create mode 100644 src/components/RoosterEditor/editorOptions/codes/PluginsCode.ts create mode 100644 src/components/RoosterEditor/editorOptions/codes/SimplePluginCode.ts create mode 100644 src/components/RoosterEditor/editorOptions/codes/WatermarkCode.ts create mode 100644 src/components/RoosterEditor/editorOptions/getReplacements.ts create mode 100644 src/components/RoosterEditor/index.jsx create mode 100644 src/components/RoosterEditor/options/defaultDomToModelOption.ts create mode 100644 src/components/RoosterEditor/options/demoUndeletableAnchorParser.ts create mode 100644 src/components/RoosterEditor/plugins/FormatPainterPlugin.ts create mode 100644 src/components/RoosterEditor/plugins/SampleEntityPlugin.ts create mode 100644 src/components/RoosterEditor/plugins/SamplePickerPlugin.tsx create mode 100644 src/components/RoosterEditor/plugins/UpdateContentPlugin.ts create mode 100644 src/components/RoosterEditor/plugins/formatpaintercursor.svg create mode 100644 src/components/RoosterEditor/snapshot/SnapshotPane.scss create mode 100644 src/components/RoosterEditor/snapshot/SnapshotPane.tsx create mode 100644 src/components/RoosterEditor/snapshot/SnapshotPlugin.tsx create mode 100644 src/components/RoosterEditor/tabs/getTabs.ts create mode 100644 src/components/RoosterEditor/tabs/ribbonButtons.ts create mode 100644 src/components/RoosterEditor/theme/theme.scss create mode 100644 src/components/RoosterEditor/theme/themes.ts create mode 100644 src/components/RoosterEditor/titleBar/TitleBar.scss create mode 100644 src/components/RoosterEditor/titleBar/TitleBar.tsx create mode 100644 src/components/RoosterEditor/titleBar/iconmonstr-github-1.svg create mode 100644 src/components/RoosterEditor/utils/cssMonitor.ts create mode 100644 src/components/RoosterEditor/utils/trustedHTMLHandler.ts diff --git a/package.json b/package.json index 08eaace..08c8fa9 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ "react-chat-elements": "^12.0.17", "react-dom": "^18.3.1", "react-router-dom": "^6.30.1", + "roosterjs": "^9.30.0", + "roosterjs-react": "^9.0.2", "rxjs": "^7.8.1", "uuid": "^9.0.1", "zustand": "^4.5.7" diff --git a/src/components/RoosterEditor/MainPane.tsx b/src/components/RoosterEditor/MainPane.tsx new file mode 100644 index 0000000..fc902d8 --- /dev/null +++ b/src/components/RoosterEditor/MainPane.tsx @@ -0,0 +1,564 @@ +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; +import SampleEntityPlugin from './plugins/SampleEntityPlugin'; +// import { ApiPlaygroundPlugin } from './sidePane/apiPlayground/ApiPlaygroundPlugin'; +// import { ContentModelPanePlugin } from './sidePane/contentModel/ContentModelPanePlugin'; +// import { darkModeButton } from './demoButtons/darkModeButton'; +import { defaultDomToModelOption } from './options/defaultDomToModelOption'; +import { Editor } from 'roosterjs-content-model-core'; +import { EditorOptionsPlugin } from './editorOptions/EditorOptionsPlugin'; +// import { EventViewPlugin } from './sidePane/eventViewer/EventViewPlugin'; +// import { exportContentButton } from './demoButtons/exportContentButton'; +import { FormatPainterPlugin } from './plugins/FormatPainterPlugin'; +// import { FormatStatePlugin } from './sidePane/formatState/FormatStatePlugin'; +import { getButtons } from './tabs/ribbonButtons'; +import { getDarkColor } from 'roosterjs-color-utils'; +// import { getPresetModelById } from './sidePane/presets/allPresets/allPresets'; +import { getTabs, tabNames } from './tabs/getTabs'; +import { getTheme } from './theme/themes'; +// import { MarkdownPanePlugin } from './sidePane/MarkdownPane/MarkdownPanePlugin'; +import { OptionState, UrlPlaceholder } from './editorOptions/OptionState'; +// import { popoutButton } from './demoButtons/popoutButton'; +// import { PresetPlugin } from './sidePane/presets/PresetPlugin'; +import { registerWindowForCss, unregisterWindowForCss } from './utils/cssMonitor'; +import { SamplePickerPlugin } from './plugins/SamplePickerPlugin'; +// // // import { SidePane } from './sidePane/SidePane'; +// // // import { SidePanePlugin } from './sidePane/SidePanePlugin'; +import { SnapshotPlugin } from './snapshot/SnapshotPlugin'; +import { ThemeProvider } from '@fluentui/react/lib/Theme'; +// import { TitleBar } from './titleBar/TitleBar'; +import { trustedHTMLHandler } from './utils/trustedHTMLHandler'; +import { undeletableLinkChecker } from './options/demoUndeletableAnchorParser'; +import { UpdateContentPlugin } from './plugins/UpdateContentPlugin'; +import { WindowProvider } from '@fluentui/react/lib/WindowProvider'; +// import { zoomButton } from './demoButtons/zoomButton'; +import type { RibbonButton, RibbonPlugin } from 'roosterjs-react'; +import { + createContextMenuPlugin, + createEmojiPlugin, + createImageEditMenuProvider, + createListEditMenuProvider, + createPasteOptionPlugin, + createRibbonPlugin, + createTableEditMenuProvider, + redoButton, + Rooster, + undoButton, + Ribbon, +} from 'roosterjs-react'; +import { + Border, + Colors, + ContentModelDocument, + EditorOptions, + EditorPlugin, + IEditor, + KnownAnnounceStrings, + Snapshots, +} from 'roosterjs-content-model-types'; +import { + AutoFormatPlugin, + CustomReplacePlugin, + EditPlugin, + HiddenPropertyPlugin, + HyperlinkPlugin, + ImageEditPlugin, + MarkdownPlugin, + PastePlugin, + ShortcutPlugin, + TableEditPlugin, + WatermarkPlugin, +} from 'roosterjs-content-model-plugins'; +// import DOMPurify = require('dompurify'); + +// const styles = require('./MainPane.scss'); + +export interface MainPaneState { + showSidePane: boolean; + popoutWindow: Window; + initState: OptionState; + scale: number; + isDarkMode: boolean; + isRtl: boolean; + activeTab: tabNames; + tableBorderFormat?: Border; + editorCreator: (div: HTMLDivElement, options: EditorOptions) => IEditor; +} + +// const PopoutRoot = 'mainPane'; +// const POPOUT_HTML = `RoosterJs Demo Site
`; +// const POPOUT_FEATURES = 'menubar=no,statusbar=no,width=1200,height=800'; +// const POPOUT_URL = 'about:blank'; +// const POPOUT_TARGET = '_blank'; + +export class MainPane extends React.Component<{}, MainPaneState> { + private mouseX: number; + private static instance: MainPane; + private popoutRoot: HTMLElement; + // private formatStatePlugin: FormatStatePlugin; + private editorOptionPlugin: EditorOptionsPlugin; + // private eventViewPlugin: EventViewPlugin; + // private apiPlaygroundPlugin: ApiPlaygroundPlugin; + // private contentModelPanePlugin: ContentModelPanePlugin; + // private presetPlugin: PresetPlugin; + private ribbonPlugin: RibbonPlugin; + private snapshotPlugin: SnapshotPlugin; + private formatPainterPlugin: FormatPainterPlugin; + private samplePickerPlugin: SamplePickerPlugin; + private snapshots: Snapshots; + private imageEditPlugin: ImageEditPlugin; + // private markdownPanePlugin: MarkdownPanePlugin; + + // protected sidePane = React.createRef(); + protected updateContentPlugin: UpdateContentPlugin; + protected model: ContentModelDocument | null = null; + private knownColors: Record = {}; + protected themeMatch = window.matchMedia?.('(prefers-color-scheme: dark)'); + + static getInstance() { + return this.instance; + } + + static readonly editorDivId = 'RoosterJsContentDiv'; + + constructor(props: {}) { + super(props); + + MainPane.instance = this; + this.updateContentPlugin = new UpdateContentPlugin(this.onUpdate); + + this.snapshots = { + snapshots: [], + totalSize: 0, + currentIndex: -1, + autoCompleteIndex: -1, + maxSize: 1e7, + }; + + // this.formatStatePlugin = new FormatStatePlugin(); + this.editorOptionPlugin = new EditorOptionsPlugin(); + // this.eventViewPlugin = new EventViewPlugin(); + // this.apiPlaygroundPlugin = new ApiPlaygroundPlugin(); + this.snapshotPlugin = new SnapshotPlugin(this.snapshots); + // this.contentModelPanePlugin = new ContentModelPanePlugin(); + // this.presetPlugin = new PresetPlugin(); + this.ribbonPlugin = createRibbonPlugin(); + this.formatPainterPlugin = new FormatPainterPlugin(); + this.samplePickerPlugin = new SamplePickerPlugin(); + this.imageEditPlugin = new ImageEditPlugin(); + // this.markdownPanePlugin = new MarkdownPanePlugin(); + + this.state = { + showSidePane: window.location.hash != '', + popoutWindow: null, + initState: this.editorOptionPlugin.getBuildInPluginState(), + scale: 1, + isDarkMode: this.themeMatch?.matches || false, + editorCreator: null, + isRtl: false, + tableBorderFormat: { + width: '1px', + style: 'solid', + color: '#ABABAB', + }, + activeTab: 'all', + }; + } + + render() { + const theme = getTheme(this.state.isDarkMode); + return ( + + {/* {this.renderTitleBar()} */} + {!this.state.popoutWindow && this.renderTabs()} + {!this.state.popoutWindow && this.renderRibbon()} +
+ {this.state.popoutWindow ? this.renderPopout() : this.renderEditor()} +
+
+ ); + } + + componentDidMount() { + this.themeMatch?.addEventListener('change', this.onThemeChange); + this.resetEditor(); + } + + componentWillUnmount() { + this.themeMatch?.removeEventListener('change', this.onThemeChange); + } + + // popout() { + // this.updateContentPlugin.update(); + + // const win = window.open(POPOUT_URL, POPOUT_TARGET, POPOUT_FEATURES); + // win.document.write( + // (DOMPurify.sanitize(POPOUT_HTML, { + // ADD_TAGS: ['head', 'meta', 'iframe'], + // ADD_ATTR: ['name', 'content'], + // WHOLE_DOCUMENT: true, + // ALLOW_UNKNOWN_PROTOCOLS: true, + // RETURN_TRUSTED_TYPE: true, + // }) as any) as string + // ); + // win.addEventListener('beforeunload', () => { + // this.updateContentPlugin.update(); + + // unregisterWindowForCss(win); + // this.setState({ popoutWindow: null }); + // }); + + // registerWindowForCss(win); + + // this.popoutRoot = win.document.getElementById(PopoutRoot); + // this.setState({ + // popoutWindow: win, + // }); + // } + + resetEditorPlugin(pluginState: OptionState) { + this.updateContentPlugin.update(); + this.setState({ + initState: pluginState, + }); + this.resetEditor(); + } + + setScale(scale: number): void { + this.setState({ + scale: scale, + }); + } + + getTableBorder(): Border { + return this.state.tableBorderFormat; + } + + setTableBorderColor(color: string): void { + this.setState({ + tableBorderFormat: { ...this.getTableBorder(), color }, + }); + } + + setTableBorderWidth(width: string): void { + this.setState({ + tableBorderFormat: { ...this.getTableBorder(), width }, + }); + } + + setTableBorderStyle(style: string): void { + this.setState({ + tableBorderFormat: { ...this.getTableBorder(), style }, + }); + } + + toggleDarkMode(): void { + this.setState({ + isDarkMode: !this.state.isDarkMode, + }); + } + + changeRibbon(id: tabNames): void { + this.setState({ + activeTab: id, + }); + } + + setPreset(preset: ContentModelDocument) { + this.model = preset; + } + + setPageDirection(isRtl: boolean): void { + this.setState({ isRtl: isRtl }); + [window, this.state.popoutWindow].forEach(win => { + if (win) { + win.document.body.dir = isRtl ? 'rtl' : 'ltr'; + } + }); + } + + // private renderTitleBar() { + // return ; + // } + + private renderTabs() { + const tabs = getTabs(); + const topRightButtons: RibbonButton[] = [ + undoButton, + redoButton, + // zoomButton, + // darkModeButton, + // exportContentButton, + ]; + // this.state.popoutWindow ? null : topRightButtons.push(popoutButton); + + return ( +
+ + +
+ ); + } + private renderRibbon() { + return ( + + ); + } + + // private renderSidePane(fullWidth: boolean) { + // return ( + // + // ); + // } + + private resetEditor() { + this.setState({ + editorCreator: (div: HTMLDivElement, options: EditorOptions) => { + return new Editor(div, options); + }, + }); + } + + private renderEditor() { + // Set preset if found + const search = new URLSearchParams(document.location.search); + const hasPreset = search.get('preset'); + if (hasPreset) { + // this.setPreset(getPresetModelById(hasPreset)); // todo: + } + + const editorStyles = { + transform: `scale(${this.state.scale})`, + transformOrigin: this.state.isRtl ? 'right top' : 'left top', + height: `calc(${100 / this.state.scale}%)`, + width: `calc(${100 / this.state.scale}%)`, + }; + const plugins: EditorPlugin[] = [ + this.ribbonPlugin, + this.formatPainterPlugin, + this.samplePickerPlugin, + ...this.getToggleablePlugins(), + // this.contentModelPanePlugin.getInnerRibbonPlugin(), + this.updateContentPlugin, + ]; + + // if (this.state.showSidePane || this.state.popoutWindow) { + // plugins.push(...this.getSidePanePlugins()); + // } + + this.updateContentPlugin.update(); + + return ( +
+
+ {this.state.editorCreator && ( + + )} +
+
+ ); + } + + private renderMainPane() { + return ( + <> + {this.renderEditor()} + {this.state.showSidePane ? ( + <> +
+ {/* {this.renderSidePane(false)} */} + {/* {this.renderSidePaneButton()} */} + + ) : ( + // this.renderSidePaneButton() + null + )} + + ); + } + + // private renderSidePaneButton() { + // return ( + // + // ); + // } + + private renderPopout() { + return ( + <> + {/* {this.renderSidePane(true )} */} + {ReactDOM.createPortal( + + +
+ {this.renderTabs()} + {this.renderRibbon()} +
{this.renderEditor()}
+
+
+
, + this.popoutRoot + )} + + ); + } + + private onMouseDown = (e: React.MouseEvent) => { + document.addEventListener('mousemove', this.onMouseMove, true); + document.addEventListener('mouseup', this.onMouseUp, true); + document.body.style.userSelect = 'none'; + this.mouseX = e.pageX; + }; + + private onMouseMove = (e: MouseEvent) => { + // this.sidePane.current.changeWidth(this.mouseX - e.pageX); + this.mouseX = e.pageX; + }; + + private onMouseUp = (e: MouseEvent) => { + document.removeEventListener('mousemove', this.onMouseMove, true); + document.removeEventListener('mouseup', this.onMouseUp, true); + document.body.style.userSelect = ''; + }; + + private onUpdate = (model: ContentModelDocument) => { + this.model = model; + }; + + private onShowSidePane = () => { + this.setState({ + showSidePane: true, + }); + this.resetEditor(); + }; + + private onHideSidePane = () => { + this.setState({ + showSidePane: false, + }); + this.resetEditor(); + window.location.hash = ''; + }; + + private onThemeChange = () => { + this.setState({ + isDarkMode: this.themeMatch?.matches || false, + }); + }; + + // private getSidePanePlugins(): SidePanePlugin[] { + // return [ + // this.formatStatePlugin, + // this.editorOptionPlugin, + // this.eventViewPlugin, + // this.apiPlaygroundPlugin, + // this.snapshotPlugin, + // this.contentModelPanePlugin, + // this.presetPlugin, + // this.markdownPanePlugin, + // ]; + // } + + private getToggleablePlugins(): EditorPlugin[] { + const { + pluginList, + allowExcelNoBorderTable, + listMenu, + tableMenu, + imageMenu, + watermarkText, + markdownOptions, + autoFormatOptions, + linkTitle, + customReplacements, + editPluginOptions, + } = this.state.initState; + return [ + pluginList.autoFormat && new AutoFormatPlugin(autoFormatOptions), + pluginList.edit && new EditPlugin(editPluginOptions), + pluginList.paste && new PastePlugin(allowExcelNoBorderTable), + pluginList.shortcut && new ShortcutPlugin(), + pluginList.tableEdit && new TableEditPlugin(), + pluginList.watermark && new WatermarkPlugin(watermarkText), + pluginList.markdown && new MarkdownPlugin(markdownOptions), + pluginList.imageEditPlugin && this.imageEditPlugin, + pluginList.emoji && createEmojiPlugin(), + pluginList.pasteOption && createPasteOptionPlugin(), + pluginList.sampleEntity && new SampleEntityPlugin(), + pluginList.contextMenu && createContextMenuPlugin(), + pluginList.contextMenu && listMenu && createListEditMenuProvider(), + pluginList.contextMenu && tableMenu && createTableEditMenuProvider(), + pluginList.contextMenu && + imageMenu && + createImageEditMenuProvider(this.imageEditPlugin), + pluginList.hyperlink && + new HyperlinkPlugin( + linkTitle?.indexOf(UrlPlaceholder) >= 0 + ? url => linkTitle.replace(UrlPlaceholder, url) + : linkTitle + ), + pluginList.customReplace && new CustomReplacePlugin(customReplacements), + pluginList.hiddenProperty && + new HiddenPropertyPlugin({ + undeletableLinkChecker: undeletableLinkChecker, + }), + ].filter(x => !!x); + } +} + +const AnnounceStringMap: Record = { + announceListItemBullet: 'Auto corrected Bullet', + announceListItemNumbering: 'Auto corrected {0}', + announceOnFocusLastCell: 'Warning, pressing tab here adds an extra row.', +}; + +function getAnnouncingString(key: KnownAnnounceStrings) { + return AnnounceStringMap[key]; +} + +export function mount(parent: HTMLElement) { + ReactDOM.render(, parent); +} diff --git a/src/components/RoosterEditor/RoosterEditor.css b/src/components/RoosterEditor/RoosterEditor.css new file mode 100644 index 0000000..34cca91 --- /dev/null +++ b/src/components/RoosterEditor/RoosterEditor.css @@ -0,0 +1,25 @@ + +/* Basic styling for the editor container */ +.rooster-editor-container { + border: 1px solid #d9d9d9; /* Ant Design border color */ + border-radius: 4px; /* Ant Design border radius */ + min-height: 200px; + padding: 8px; /* Ant Design default padding for inputs */ + box-sizing: border-box; /* Ensures padding is included in width */ +} + +/* Style the actual editor div (RoosterJs injects content here) */ +.rooster-editor { + min-height: 180px; /* Adjust as needed */ + outline: none; /* Remove default focus outline */ +} + +/* Optional: Basic styling for the content within the editor */ +.rooster-editor p { + margin-bottom: 8px; /* Space out paragraphs */ +} + +.rooster-editor ul, +.rooster-editor ol { + padding-left: 20px; /* Indent lists */ +} diff --git a/src/components/RoosterEditor/SidePanePlugin.ts b/src/components/RoosterEditor/SidePanePlugin.ts new file mode 100644 index 0000000..f15a4fd --- /dev/null +++ b/src/components/RoosterEditor/SidePanePlugin.ts @@ -0,0 +1,7 @@ +import type { EditorPlugin } from 'roosterjs-content-model-types'; + +export interface SidePanePlugin extends EditorPlugin { + getTitle: () => string; + renderSidePane: (updateHash: (pluginName?: string, path?: string[]) => void) => JSX.Element; + setHashPath?: (path: string[]) => void; +} diff --git a/src/components/RoosterEditor/editorOptions/Code.tsx b/src/components/RoosterEditor/editorOptions/Code.tsx new file mode 100644 index 0000000..fb99058 --- /dev/null +++ b/src/components/RoosterEditor/editorOptions/Code.tsx @@ -0,0 +1,15 @@ +import * as React from 'react'; + +export interface CodeProps { + code: string; +} + +export class Code extends React.Component { + render() { + return ( +
+
{this.props.code}
+
+ ); + } +} diff --git a/src/components/RoosterEditor/editorOptions/DefaultFormatPane.tsx b/src/components/RoosterEditor/editorOptions/DefaultFormatPane.tsx new file mode 100644 index 0000000..302d448 --- /dev/null +++ b/src/components/RoosterEditor/editorOptions/DefaultFormatPane.tsx @@ -0,0 +1,146 @@ +import * as React from 'react'; +import { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; +import { getObjectKeys } from 'roosterjs-content-model-dom'; +import { OptionState } from './OptionState'; + +type ToggleFormatId = 'fontWeight' | 'italic' | 'underline'; +type SelectFormatId = 'textColor' | 'backgroundColor' | 'fontFamily' | 'fontSize'; + +const styles = require('./OptionsPane.scss'); +const NOT_SET = 'NotSet'; + +export interface DefaultFormatProps { + state: ContentModelSegmentFormat; + resetState: (callback: (state: OptionState) => void, resetEditor: boolean) => void; +} + +export class DefaultFormatPane extends React.Component { + render() { + return ( + <> + + + {this.renderFormatItem('fontWeight', 'Bold')} + {this.renderFormatItem('italic', 'Italic')} + {this.renderFormatItem('underline', 'Underline')} + +
+ + + {this.renderSelectItem('fontFamily', 'Font family: ', { + [NOT_SET]: 'Not Set', + Arial: 'Arial', + Calibri: 'Calibri', + 'Courier New': 'Courier New', + Tahoma: 'Tahoma', + 'Times New Roman': 'Times New Roman', + })} + {this.renderSelectItem('fontSize', 'Font size: ', { + [NOT_SET]: 'Not Set', + '8pt': '8', + '10pt': '10', + '11pt': '11', + '12pt': '12', + '16pt': '16', + '20pt': '20', + '36pt': '36', + '72pt': '72', + })} + {this.renderSelectItem('textColor', 'Text color: ', { + [NOT_SET]: 'Not Set', + '#757b80': 'Gray', + '#bd1398': 'Violet', + '#7232ad': 'Purple', + '#006fc9': 'Blue', + '#4ba524': 'Green', + '#e2c501': 'Yellow', + '#d05c12': 'Orange', + '#ff0000': 'Red', + '#ffffff': 'White', + '#000000': 'Black', + })} + {this.renderSelectItem('backgroundColor', 'Back color: ', { + [NOT_SET]: 'Not Set', + '#ffff00': 'Yellow', + '#00ff00': 'Green', + '#00ffff': 'Cyan', + '#ff00ff': 'Purple', + '#0000ff': 'Blue', + '#ff0000': 'Red', + '#bebebe': 'Gray', + '#666666': 'Dark Gray', + '#ffffff': 'White', + '#000000': 'Black', + })} + +
+ + ); + } + + private renderFormatItem(id: ToggleFormatId, text: string): JSX.Element { + let checked = !!this.props.state[id]; + + return ( + + + this.onFormatClick(id)} + /> + + +
+ +
+ + + ); + } + + private renderSelectItem( + id: SelectFormatId, + label: string, + items: { [key: string]: string } + ): JSX.Element { + return ( + + {label} + + + + + ); + } + + private onFormatClick = (id: ToggleFormatId) => { + this.props.resetState(state => { + let checkbox = document.getElementById(id) as HTMLInputElement; + + if (id == 'fontWeight') { + state.defaultFormat.fontWeight = checkbox.checked ? 'bold' : undefined; + } else { + state.defaultFormat[id] = checkbox.checked; + } + }, true); + }; + + private onSelectChanged = (id: SelectFormatId) => { + this.props.resetState(state => { + let value = (document.getElementById(id) as HTMLSelectElement).value; + + state.defaultFormat[id] = value == NOT_SET ? undefined : value; + }, true); + }; +} diff --git a/src/components/RoosterEditor/editorOptions/EditorOptionsPlugin.ts b/src/components/RoosterEditor/editorOptions/EditorOptionsPlugin.ts new file mode 100644 index 0000000..1312a41 --- /dev/null +++ b/src/components/RoosterEditor/editorOptions/EditorOptionsPlugin.ts @@ -0,0 +1,88 @@ +import { emojiReplacements } from './getReplacements'; +import { ExperimentalFeature } from 'roosterjs-content-model-types'; +import { OptionPaneProps, OptionState, UrlPlaceholder } from './OptionState'; +import { OptionsPane } from './OptionsPane'; +import { SidePaneElementProps } from '../SidePaneElement'; +import { SidePanePluginImpl } from '../SidePanePluginImpl'; + +const initialState: OptionState = { + pluginList: { + autoFormat: true, + edit: true, + paste: true, + shortcut: true, + tableEdit: true, + contextMenu: true, + watermark: true, + emoji: true, + pasteOption: true, + sampleEntity: true, + markdown: true, + imageEditPlugin: true, + hyperlink: true, + customReplace: true, + hiddenProperty: true, + }, + defaultFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + linkTitle: 'Ctrl+Click to follow the link:' + UrlPlaceholder, + watermarkText: 'Type content here ...', + forcePreserveRatio: false, + isRtl: false, + disableCache: false, + tableFeaturesContainerSelector: '#' + 'EditorContainer', + allowExcelNoBorderTable: false, + imageMenu: true, + tableMenu: true, + listMenu: true, + autoFormatOptions: { + autoBullet: true, + autoLink: true, + autoNumbering: true, + autoUnlink: false, + autoHyphen: true, + autoFraction: true, + autoOrdinals: true, + autoMailto: true, + autoTel: true, + removeListMargins: false, + autoHorizontalLine: true, + }, + markdownOptions: { + bold: true, + italic: true, + strikethrough: true, + codeFormat: {}, + }, + editPluginOptions: { + handleTabKey: true, + }, + customReplacements: emojiReplacements, + experimentalFeatures: new Set([ + 'PersistCache', + 'HandleEnterKey', + 'CustomCopyCut', + ]), +}; + +export class EditorOptionsPlugin extends SidePanePluginImpl { + constructor() { + super(OptionsPane, 'options', 'Editor Options'); + } + + getBuildInPluginState(): OptionState { + let result: OptionState; + this.getComponent(component => (result = component.getState())); + return result || initialState; + } + + getComponentProps(base: SidePaneElementProps) { + return { + ...initialState, + ...base, + }; + } +} diff --git a/src/components/RoosterEditor/editorOptions/ExperimentalFeatures.tsx b/src/components/RoosterEditor/editorOptions/ExperimentalFeatures.tsx new file mode 100644 index 0000000..32f18ed --- /dev/null +++ b/src/components/RoosterEditor/editorOptions/ExperimentalFeatures.tsx @@ -0,0 +1,48 @@ +import * as React from 'react'; +import { ExperimentalFeature } from 'roosterjs-content-model-types'; +import { OptionState } from './OptionState'; + +export interface DefaultFormatProps { + state: OptionState; + resetState: (callback: (state: OptionState) => void, resetEditor: boolean) => void; +} + +export class ExperimentalFeatures extends React.Component { + render() { + return ( + <> + {this.renderFeature('PersistCache')} + {this.renderFeature('HandleEnterKey')} + {this.renderFeature('CustomCopyCut')} + + ); + } + + private renderFeature(featureName: ExperimentalFeature): JSX.Element { + let checked = this.props.state.experimentalFeatures.has(featureName); + + return ( +
+ this.onFeatureClick(featureName)} + /> + +
+ ); + } + + private onFeatureClick = (featureName: ExperimentalFeature) => { + this.props.resetState(state => { + let checkbox = document.getElementById(featureName) as HTMLInputElement; + + if (checkbox.checked) { + state.experimentalFeatures.add(featureName); + } else { + state.experimentalFeatures.delete(featureName); + } + }, true); + }; +} diff --git a/src/components/RoosterEditor/editorOptions/OptionState.ts b/src/components/RoosterEditor/editorOptions/OptionState.ts new file mode 100644 index 0000000..bb37560 --- /dev/null +++ b/src/components/RoosterEditor/editorOptions/OptionState.ts @@ -0,0 +1,56 @@ +import { + AutoFormatOptions, + CustomReplace, + EditOptions, + MarkdownOptions, +} from 'roosterjs-content-model-plugins'; +import type { SidePaneElementProps } from '../SidePaneElement'; +import type { ContentModelSegmentFormat, ExperimentalFeature } from 'roosterjs-content-model-types'; + +export interface BuildInPluginList { + autoFormat: boolean; + edit: boolean; + paste: boolean; + shortcut: boolean; + tableEdit: boolean; + contextMenu: boolean; + watermark: boolean; + emoji: boolean; + pasteOption: boolean; + sampleEntity: boolean; + markdown: boolean; + hyperlink: boolean; + imageEditPlugin: boolean; + customReplace: boolean; + hiddenProperty: boolean; +} + +export interface OptionState { + pluginList: BuildInPluginList; + + // New plugin options + allowExcelNoBorderTable: boolean; + listMenu: boolean; + tableMenu: boolean; + imageMenu: boolean; + watermarkText: string; + autoFormatOptions: AutoFormatOptions; + markdownOptions: MarkdownOptions; + customReplacements: CustomReplace[]; + editPluginOptions: EditOptions; + + // Legacy plugin options + defaultFormat: ContentModelSegmentFormat; + linkTitle: string; + forcePreserveRatio: boolean; + tableFeaturesContainerSelector: string; + + // Editor options + isRtl: boolean; + disableCache: boolean; + experimentalFeatures: Set; +} + +export interface OptionPaneProps extends OptionState, SidePaneElementProps {} + +export const UrlPlaceholder = '$url$'; diff --git a/src/components/RoosterEditor/editorOptions/OptionsPane.scss b/src/components/RoosterEditor/editorOptions/OptionsPane.scss new file mode 100644 index 0000000..8cb7419 --- /dev/null +++ b/src/components/RoosterEditor/editorOptions/OptionsPane.scss @@ -0,0 +1,7 @@ +.checkboxColumn { + vertical-align: top; +} + +.defaultFormatLabel { + white-space: nowrap; +} diff --git a/src/components/RoosterEditor/editorOptions/OptionsPane.tsx b/src/components/RoosterEditor/editorOptions/OptionsPane.tsx new file mode 100644 index 0000000..951aa42 --- /dev/null +++ b/src/components/RoosterEditor/editorOptions/OptionsPane.tsx @@ -0,0 +1,187 @@ +import * as React from 'react'; +import { Code } from './Code'; +import { DefaultFormatPane } from './DefaultFormatPane'; +import { EditorCode } from './codes/EditorCode'; +import { ExperimentalFeatures } from './ExperimentalFeatures'; +import { MainPane } from '../../mainPane/MainPane'; +import { OptionPaneProps, OptionState } from './OptionState'; +import { Plugins } from './Plugins'; + +const htmlStart = + '\n' + + '\n' + + '
\n'; +const htmlButtons = + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n'; +'\n'; +const jsCode = '\n'; +const htmlEnd = '\n' + ''; + +export class OptionsPane extends React.Component { + private exportForm = React.createRef(); + private exportData = React.createRef(); + private rtl = React.createRef(); + private disableCache = React.createRef(); + + constructor(props: OptionPaneProps) { + super(props); + this.state = { ...props }; + } + render() { + const editorCode = new EditorCode(this.state); + const html = this.getHtml(); + + return ( +
+
+ + Default Format + + +
+
+ + Plugins + + +
+
+ + Experimental features + + +
+
+
+
+
+ + +
+
+ + +
+
+
+ +
+
+
+
+
+ + HTML Code: + + +
+
+ + Typescript Code: + + +
+
+ +
+
+ ); + } + + getState(): OptionState { + return { ...this.state }; + } + + private resetState = (callback: (state: OptionState) => void, resetEditor: boolean) => { + let state: OptionState = { + linkTitle: this.state.linkTitle, + watermarkText: this.state.watermarkText, + pluginList: { ...this.state.pluginList }, + defaultFormat: { ...this.state.defaultFormat }, + forcePreserveRatio: this.state.forcePreserveRatio, + + isRtl: this.state.isRtl, + disableCache: this.state.disableCache, + tableFeaturesContainerSelector: this.state.tableFeaturesContainerSelector, + allowExcelNoBorderTable: this.state.allowExcelNoBorderTable, + listMenu: this.state.listMenu, + tableMenu: this.state.tableMenu, + imageMenu: this.state.imageMenu, + autoFormatOptions: { ...this.state.autoFormatOptions }, + markdownOptions: { ...this.state.markdownOptions }, + customReplacements: this.state.customReplacements, + experimentalFeatures: this.state.experimentalFeatures, + editPluginOptions: { ...this.state.editPluginOptions }, + }; + + if (callback) { + callback(state); + this.setState(state); + } + + if (resetEditor) { + MainPane.getInstance().resetEditorPlugin(state); + } + }; + + private onExportRoosterContentModel = () => { + let editor = new EditorCode(this.state); + let code = editor.getCode(); + let json = { + title: 'RoosterJs', + html: this.getHtml(), + head: '', + js: code, + js_pre_processor: 'typescript', + }; + this.exportData.current.value = JSON.stringify(json); + this.exportForm.current.submit(); + }; + + private onToggleDirection = () => { + let isRtl = this.rtl.current.checked; + this.setState({ + isRtl: isRtl, + }); + MainPane.getInstance().setPageDirection(isRtl); + }; + + private onToggleCacheModel = () => { + this.resetState(state => { + state.disableCache = this.disableCache.current.checked; + }, true); + }; + + private getHtml() { + return `${htmlStart}${htmlButtons}${jsCode}${htmlEnd}`; + } +} diff --git a/src/components/RoosterEditor/editorOptions/Plugins.tsx b/src/components/RoosterEditor/editorOptions/Plugins.tsx new file mode 100644 index 0000000..986eb3f --- /dev/null +++ b/src/components/RoosterEditor/editorOptions/Plugins.tsx @@ -0,0 +1,323 @@ +import * as React from 'react'; +import { UrlPlaceholder } from './OptionState'; +import type { BuildInPluginList, OptionState } from './OptionState'; + +const styles = require('./OptionsPane.scss'); + +export interface PluginsProps { + state: OptionState; + resetState: (callback: (state: OptionState) => void, resetEditor: boolean) => void; +} + +abstract class PluginsBase extends React.Component< + PluginsProps, + {} +> { + abstract render(): JSX.Element; + + protected renderPluginItem( + id: PluginKey, + text: string, + moreOptions?: JSX.Element + ): JSX.Element { + const checked = this.props.state.pluginList[id]; + + return ( + + + this.onPluginClick(id)} + /> + + +
+ +
+ {checked && moreOptions} + + + ); + } + + protected renderInputBox( + label: string, + ref: React.RefObject, + value: string, + placeholder: string, + onChange: (state: OptionState, value: string) => void + ): JSX.Element { + return ( +
+ {label} + + this.props.resetState(state => onChange(state, ref.current.value), false) + } + onBlur={() => this.props.resetState(null, true)} + /> +
+ ); + } + + protected renderCheckBox( + label: string, + ref: React.RefObject, + value: boolean, + onChange: (state: OptionState, value: boolean) => void + ): JSX.Element { + return ( +
+ + this.props.resetState(state => onChange(state, ref.current.checked), true) + } + onBlur={() => this.props.resetState(null, true)} + /> + {label} +
+ ); + } + + private onPluginClick = (id: PluginKey) => { + this.props.resetState(state => { + let checkbox = document.getElementById(id) as HTMLInputElement; + state.pluginList[id] = checkbox.checked; + }, true); + }; +} + +export class Plugins extends PluginsBase { + private allowExcelNoBorderTable = React.createRef(); + private handleTabKey = React.createRef(); + private handleEnterKey = React.createRef(); + private listMenu = React.createRef(); + private tableMenu = React.createRef(); + private imageMenu = React.createRef(); + private watermarkText = React.createRef(); + private autoBullet = React.createRef(); + private autoNumbering = React.createRef(); + private autoLink = React.createRef(); + private autoUnlink = React.createRef(); + private autoHyphen = React.createRef(); + private autoFraction = React.createRef(); + private autoOrdinals = React.createRef(); + private autoTel = React.createRef(); + private autoMailto = React.createRef(); + private removeListMargins = React.createRef(); + private horizontalLine = React.createRef(); + private markdownBold = React.createRef(); + private markdownItalic = React.createRef(); + private markdownStrikethrough = React.createRef(); + private markdownCode = React.createRef(); + private linkTitle = React.createRef(); + + render(): JSX.Element { + return ( + + + {this.renderPluginItem( + 'autoFormat', + 'AutoFormat', + <> + {this.renderCheckBox( + 'Bullet', + this.autoBullet, + this.props.state.autoFormatOptions.autoBullet, + (state, value) => (state.autoFormatOptions.autoBullet = value) + )} + {this.renderCheckBox( + 'Numbering', + this.autoNumbering, + this.props.state.autoFormatOptions.autoNumbering, + (state, value) => (state.autoFormatOptions.autoNumbering = value) + )} + {this.renderCheckBox( + 'Link', + this.autoLink, + this.props.state.autoFormatOptions.autoLink, + (state, value) => (state.autoFormatOptions.autoLink = value) + )} + {this.renderCheckBox( + 'Unlink', + this.autoUnlink, + this.props.state.autoFormatOptions.autoUnlink, + (state, value) => (state.autoFormatOptions.autoUnlink = value) + )} + {this.renderCheckBox( + 'Hyphen', + this.autoHyphen, + this.props.state.autoFormatOptions.autoHyphen, + (state, value) => (state.autoFormatOptions.autoHyphen = value) + )} + {this.renderCheckBox( + 'Fraction', + this.autoFraction, + this.props.state.autoFormatOptions.autoFraction, + (state, value) => (state.autoFormatOptions.autoFraction = value) + )} + {this.renderCheckBox( + 'Ordinals', + this.autoOrdinals, + this.props.state.autoFormatOptions.autoOrdinals, + (state, value) => (state.autoFormatOptions.autoOrdinals = value) + )} + {this.renderCheckBox( + 'Telephone', + this.autoTel, + this.props.state.autoFormatOptions.autoTel, + (state, value) => (state.autoFormatOptions.autoTel = value) + )} + {this.renderCheckBox( + 'Email', + this.autoMailto, + this.props.state.autoFormatOptions.autoMailto, + (state, value) => (state.autoFormatOptions.autoMailto = value) + )} + {this.renderCheckBox( + 'Remove List Margins', + this.removeListMargins, + this.props.state.autoFormatOptions.removeListMargins, + (state, value) => + (state.autoFormatOptions.removeListMargins = value) + )} + {this.renderCheckBox( + 'Horizontal Line', + this.horizontalLine, + this.props.state.autoFormatOptions.autoHorizontalLine, + (state, value) => + (state.autoFormatOptions.autoHorizontalLine = value) + )} + + )} + {this.renderPluginItem( + 'edit', + 'Edit', + <> + {this.renderCheckBox( + 'Handle Tab Key', + this.handleTabKey, + this.props.state.editPluginOptions.handleTabKey, + (state, value) => (state.editPluginOptions.handleTabKey = value) + )} + {this.renderCheckBox( + 'Handle Enter Key', + this.handleEnterKey, + this.props.state.editPluginOptions.shouldHandleEnterKey as boolean, + (state, value) => + (state.editPluginOptions.shouldHandleEnterKey = value) + )} + + )} + {this.renderPluginItem( + 'paste', + 'Paste', + this.renderCheckBox( + 'Do not add border for Excel table', + this.allowExcelNoBorderTable, + this.props.state.allowExcelNoBorderTable, + (state, value) => (state.allowExcelNoBorderTable = value) + ) + )} + {this.renderPluginItem('shortcut', 'Shortcut')} + {this.renderPluginItem('tableEdit', 'TableEdit')} + {this.renderPluginItem( + 'contextMenu', + 'ContextMenu', + <> + {this.renderCheckBox( + 'List menu', + this.listMenu, + this.props.state.listMenu, + (state, value) => (state.listMenu = value) + )} + {this.renderCheckBox( + 'Table menu', + this.tableMenu, + this.props.state.tableMenu, + (state, value) => (state.tableMenu = value) + )} + {this.renderCheckBox( + 'Image menu', + this.imageMenu, + this.props.state.imageMenu, + (state, value) => (state.imageMenu = value) + )} + + )} + {this.renderPluginItem( + 'watermark', + 'Watermark Plugin', + this.renderInputBox( + 'Watermark text: ', + this.watermarkText, + this.props.state.watermarkText, + '', + (state, value) => (state.watermarkText = value) + ) + )} + {this.renderPluginItem('emoji', 'Emoji')} + {this.renderPluginItem('pasteOption', 'PasteOptions')} + {this.renderPluginItem('sampleEntity', 'SampleEntity')} + {this.renderPluginItem( + 'markdown', + 'Markdown', + <> + {this.renderCheckBox( + 'Bold', + this.markdownBold, + this.props.state.markdownOptions.bold, + (state, value) => (state.markdownOptions.bold = value) + )} + {this.renderCheckBox( + 'Italic', + this.markdownItalic, + this.props.state.markdownOptions.italic, + (state, value) => (state.markdownOptions.italic = value) + )} + {this.renderCheckBox( + 'Strikethrough', + this.markdownStrikethrough, + this.props.state.markdownOptions.strikethrough, + (state, value) => (state.markdownOptions.strikethrough = value) + )} + + {this.renderCheckBox( + 'Code', + this.markdownCode, + this.props.state.markdownOptions.codeFormat !== undefined, + (state, value) => + value + ? (state.markdownOptions.codeFormat = {}) + : (state.markdownOptions.codeFormat = undefined) + )} + + )} + {this.renderPluginItem( + 'hyperlink', + 'Hyperlink Plugin', + this.renderInputBox( + 'Label title: ', + this.linkTitle, + this.props.state.linkTitle, + 'Use "' + UrlPlaceholder + '" for the url string', + (state, value) => (state.linkTitle = value) + ) + )} + {this.renderPluginItem('customReplace', 'Custom Replace')} + {this.renderPluginItem('imageEditPlugin', 'ImageEditPlugin')} + {this.renderPluginItem('hiddenProperty', 'Hidden Property')} + +
+ ); + } +} diff --git a/src/components/RoosterEditor/editorOptions/codes/AutoFormatCode.ts b/src/components/RoosterEditor/editorOptions/codes/AutoFormatCode.ts new file mode 100644 index 0000000..2cddd39 --- /dev/null +++ b/src/components/RoosterEditor/editorOptions/codes/AutoFormatCode.ts @@ -0,0 +1,20 @@ +import { AutoFormatOptions } from 'roosterjs-content-model-plugins'; +import { CodeElement } from './CodeElement'; + +export class AutoFormatCode extends CodeElement { + constructor(private options: AutoFormatOptions) { + super(); + } + + getCode() { + return `new roosterjs.AutoFormatPlugin({ + autoBullet: ${this.options.autoBullet}, + autoLink: ${this.options.autoLink}, + autoNumbering: ${this.options.autoNumbering}, + autoUnlink: ${this.options.autoUnlink}, + autoHyphen: ${this.options.autoHyphen}, + autoFraction: ${this.options.autoFraction}, + autoOrdinals: ${this.options.autoOrdinals}, + })`; + } +} diff --git a/src/components/RoosterEditor/editorOptions/codes/ButtonsCode.ts b/src/components/RoosterEditor/editorOptions/codes/ButtonsCode.ts new file mode 100644 index 0000000..b800b39 --- /dev/null +++ b/src/components/RoosterEditor/editorOptions/codes/ButtonsCode.ts @@ -0,0 +1,26 @@ +import { CodeElement } from './CodeElement'; +import { getObjectKeys } from 'roosterjs-content-model-dom'; + +const codeMap: { [id: string]: string } = { + buttonB: 'roosterjs.toggleBold(editor)', + buttonI: 'roosterjs.toggleItalic(editor)', + buttonU: 'roosterjs.toggleUnderline(editor)', + buttonBullet: 'roosterjs.toggleBullet(editor)', + buttonNumbering: 'roosterjs.toggleNumbering(editor)', + buttonUndo: 'roosterjs.undo(editor)', + buttonRedo: 'roosterjs.redo(editor)', + buttonTable: 'roosterjs.insertTable(editor, 3, 3)', + buttonDark: 'editor.setDarkModeState(!editor.isDarkMode())', +}; + +export class ButtonsCode extends CodeElement { + getCode() { + const map = { ...codeMap }; + return getObjectKeys(map) + .map( + id => + `document.getElementById('${id}').addEventListener('click', () => ${map[id]});\n` + ) + .join(''); + } +} diff --git a/src/components/RoosterEditor/editorOptions/codes/CodeElement.ts b/src/components/RoosterEditor/editorOptions/codes/CodeElement.ts new file mode 100644 index 0000000..958d021 --- /dev/null +++ b/src/components/RoosterEditor/editorOptions/codes/CodeElement.ts @@ -0,0 +1,14 @@ +export abstract class CodeElement { + abstract getCode(): string; + + protected encode(src: string): string { + return src.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); + } + + protected indent(src: string): string { + return src + .split('\n') + .map(line => (line == '' ? '' : ' ' + line + '\n')) + .join(''); + } +} diff --git a/src/components/RoosterEditor/editorOptions/codes/DarkModeCode.ts b/src/components/RoosterEditor/editorOptions/codes/DarkModeCode.ts new file mode 100644 index 0000000..0c2f9a4 --- /dev/null +++ b/src/components/RoosterEditor/editorOptions/codes/DarkModeCode.ts @@ -0,0 +1,7 @@ +import { CodeElement } from './CodeElement'; + +export class DarkModeCode extends CodeElement { + getCode() { + return 'roosterjs.getDarkColor'; + } +} diff --git a/src/components/RoosterEditor/editorOptions/codes/DefaultFormatCode.ts b/src/components/RoosterEditor/editorOptions/codes/DefaultFormatCode.ts new file mode 100644 index 0000000..33b5cf6 --- /dev/null +++ b/src/components/RoosterEditor/editorOptions/codes/DefaultFormatCode.ts @@ -0,0 +1,31 @@ +import { CodeElement } from './CodeElement'; +import { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; + +export class DefaultFormatCode extends CodeElement { + constructor(private defaultFormat: ContentModelSegmentFormat) { + super(); + } + + getCode() { + let { + fontWeight, + italic, + underline, + fontFamily, + fontSize, + textColor, + backgroundColor, + } = this.defaultFormat; + let lines = [ + fontWeight ? `fontWeight: '${fontWeight}',\n` : null, + italic ? 'italic: true,\n' : null, + underline ? 'underline: true,\n' : null, + fontFamily ? `fontFamily: '${this.encode(fontFamily)}',\n` : null, + fontSize ? `fontSize: '${this.encode(fontSize)}',\n` : null, + textColor ? `textColor: '${this.encode(textColor)}',\n` : null, + backgroundColor ? `backgroundColor: '${this.encode(backgroundColor)}',\n` : null, + ].filter(line => !!line); + + return lines.length > 0 ? '{\n' + this.indent(lines.join('')) + '}' : ''; + } +} diff --git a/src/components/RoosterEditor/editorOptions/codes/EditorCode.ts b/src/components/RoosterEditor/editorOptions/codes/EditorCode.ts new file mode 100644 index 0000000..aa425d5 --- /dev/null +++ b/src/components/RoosterEditor/editorOptions/codes/EditorCode.ts @@ -0,0 +1,40 @@ +import { ButtonsCode } from './ButtonsCode'; +import { CodeElement } from './CodeElement'; +import { DarkModeCode } from './DarkModeCode'; +import { DefaultFormatCode } from './DefaultFormatCode'; +import { PluginsCode } from './PluginsCode'; +import type { OptionState } from '../OptionState'; + +export class EditorCode extends CodeElement { + private plugins: PluginsCode; + private defaultFormat: DefaultFormatCode; + private buttons: ButtonsCode; + private darkMode: DarkModeCode; + + constructor(state: OptionState) { + super(); + + this.plugins = new PluginsCode(state); + this.defaultFormat = new DefaultFormatCode(state.defaultFormat); + this.buttons = new ButtonsCode(); + this.darkMode = new DarkModeCode(); + } + + getCode() { + let defaultFormat = this.defaultFormat.getCode(); + let code = "let contentDiv = document.getElementById('contentDiv');\n"; + let darkMode = this.darkMode.getCode(); + + code += `let plugins = ${this.plugins.getCode()};\n`; + code += defaultFormat ? `let defaultSegmentFormat = ${defaultFormat};\n` : ''; + code += 'let options = {\n'; + code += this.indent('plugins: plugins,\n'); + code += defaultFormat ? this.indent('defaultSegmentFormat: defaultSegmentFormat,\n') : ''; + code += this.indent(`getDarkColor: ${darkMode},\n`); + code += '};\n'; + code += `let editor = new roosterjs.Editor(contentDiv, options);\n`; + code += this.buttons ? this.buttons.getCode() : ''; + + return code; + } +} diff --git a/src/components/RoosterEditor/editorOptions/codes/HyperLinkCode.ts b/src/components/RoosterEditor/editorOptions/codes/HyperLinkCode.ts new file mode 100644 index 0000000..f4ad899 --- /dev/null +++ b/src/components/RoosterEditor/editorOptions/codes/HyperLinkCode.ts @@ -0,0 +1,32 @@ +import { CodeElement } from './CodeElement'; +import { UrlPlaceholder } from '../OptionState'; + +export class HyperLinkCode extends CodeElement { + constructor(private linkTitle: string) { + super(); + } + + getCode() { + return 'new roosterjs.HyperlinkPlugin(' + this.getLinkCallback() + ')'; + } + + private getLinkCallback() { + if (!this.linkTitle) { + return ''; + } + + let index = this.linkTitle.indexOf(UrlPlaceholder); + if (index >= 0) { + let left = this.linkTitle.substr(0, index); + let right = this.linkTitle.substr(index + UrlPlaceholder.length); + return ( + 'url => ' + + (left ? `'${this.encode(left)}' + ` : '') + + 'url' + + (right ? ` + '${this.encode(right)}'` : '') + ); + } else { + return `() => '${this.linkTitle}'`; + } + } +} diff --git a/src/components/RoosterEditor/editorOptions/codes/MarkdownCode.ts b/src/components/RoosterEditor/editorOptions/codes/MarkdownCode.ts new file mode 100644 index 0000000..f089888 --- /dev/null +++ b/src/components/RoosterEditor/editorOptions/codes/MarkdownCode.ts @@ -0,0 +1,17 @@ +import { CodeElement } from './CodeElement'; +import { MarkdownOptions } from 'roosterjs-content-model-plugins'; + +export class MarkdownCode extends CodeElement { + constructor(private markdownOptions: MarkdownOptions) { + super(); + } + + getCode() { + return `new roosterjs.MarkdownPlugin({ + bold: ${this.markdownOptions.bold}, + italic: ${this.markdownOptions.italic}, + strikethrough: ${this.markdownOptions.strikethrough}, + codeFormat: ${JSON.stringify(this.markdownOptions.codeFormat)}, + })`; + } +} diff --git a/src/components/RoosterEditor/editorOptions/codes/PluginsCode.ts b/src/components/RoosterEditor/editorOptions/codes/PluginsCode.ts new file mode 100644 index 0000000..63cbde5 --- /dev/null +++ b/src/components/RoosterEditor/editorOptions/codes/PluginsCode.ts @@ -0,0 +1,50 @@ +import { AutoFormatCode } from './AutoFormatCode'; +import { CodeElement } from './CodeElement'; +import { MarkdownCode } from './MarkdownCode'; +import { OptionState } from '../OptionState'; +import { WatermarkCode } from './WatermarkCode'; + +import { + EditPluginCode, + PastePluginCode, + TableEditPluginCode, + ShortcutPluginCode, + ImageEditPluginCode, +} from './SimplePluginCode'; + +export class PluginsCodeBase extends CodeElement { + private plugins: CodeElement[]; + constructor(plugins: CodeElement[]) { + super(); + + this.plugins = plugins.filter(plugin => !!plugin); + } + + getPluginCount() { + return this.plugins.length; + } + + getCode() { + let code = '[\n'; + code += this.indent(this.plugins.map(plugin => plugin.getCode() + ',\n').join('')); + code += ']'; + return code; + } +} + +export class PluginsCode extends PluginsCodeBase { + constructor(state: OptionState) { + const pluginList = state.pluginList; + + super([ + pluginList.autoFormat && new AutoFormatCode(state.autoFormatOptions), + pluginList.edit && new EditPluginCode(), + pluginList.paste && new PastePluginCode(), + pluginList.tableEdit && new TableEditPluginCode(), + pluginList.shortcut && new ShortcutPluginCode(), + pluginList.watermark && new WatermarkCode(state.watermarkText), + pluginList.markdown && new MarkdownCode(state.markdownOptions), + pluginList.imageEditPlugin && new ImageEditPluginCode(), + ]); + } +} diff --git a/src/components/RoosterEditor/editorOptions/codes/SimplePluginCode.ts b/src/components/RoosterEditor/editorOptions/codes/SimplePluginCode.ts new file mode 100644 index 0000000..b078ab5 --- /dev/null +++ b/src/components/RoosterEditor/editorOptions/codes/SimplePluginCode.ts @@ -0,0 +1,41 @@ +import { CodeElement } from './CodeElement'; + +class SimplePluginCode extends CodeElement { + constructor(private name: string, private namespace: string = 'roosterjs') { + super(); + } + + getCode() { + return `new ${this.namespace}.${this.name}()`; + } +} + +export class EditPluginCode extends SimplePluginCode { + constructor() { + super('EditPlugin'); + } +} + +export class PastePluginCode extends SimplePluginCode { + constructor() { + super('PastePlugin'); + } +} + +export class ShortcutPluginCode extends SimplePluginCode { + constructor() { + super('ShortcutPlugin'); + } +} + +export class TableEditPluginCode extends SimplePluginCode { + constructor() { + super('TableEditPlugin'); + } +} + +export class ImageEditPluginCode extends SimplePluginCode { + constructor() { + super('ImageEditPlugin'); + } +} diff --git a/src/components/RoosterEditor/editorOptions/codes/WatermarkCode.ts b/src/components/RoosterEditor/editorOptions/codes/WatermarkCode.ts new file mode 100644 index 0000000..9a7243c --- /dev/null +++ b/src/components/RoosterEditor/editorOptions/codes/WatermarkCode.ts @@ -0,0 +1,11 @@ +import { CodeElement } from './CodeElement'; + +export class WatermarkCode extends CodeElement { + constructor(private watermarkText: string) { + super(); + } + + getCode() { + return `new roosterjs.WatermarkPlugin('${this.encode(this.watermarkText)}')`; + } +} diff --git a/src/components/RoosterEditor/editorOptions/getReplacements.ts b/src/components/RoosterEditor/editorOptions/getReplacements.ts new file mode 100644 index 0000000..1a9e243 --- /dev/null +++ b/src/components/RoosterEditor/editorOptions/getReplacements.ts @@ -0,0 +1,37 @@ +import { ContentModelText } from 'roosterjs-content-model-types'; +import { CustomReplace } from 'roosterjs-content-model-plugins'; + +function replaceEmojis( + previousSegment: ContentModelText, + stringToReplace: string, + replacement: string +) { + const { text } = previousSegment; + const queryString = text.split(' ').pop(); + if (queryString === stringToReplace) { + previousSegment.text = text.replace(stringToReplace, replacement); + return true; + } + return false; +} + +function makeEmojiReplacements(stringToReplace: string, replacement: string) { + return { + stringToReplace, + replacementString: replacement, + replacementHandler: replaceEmojis, + }; +} + +export const emojiReplacements: CustomReplace[] = [ + makeEmojiReplacements(';)', '😉'), + makeEmojiReplacements(';-)', '😉'), + makeEmojiReplacements(';P', '😜'), + makeEmojiReplacements(';-P', '😜'), + makeEmojiReplacements('<3', '❤️'), + makeEmojiReplacements(' { + const editorDivRef = useRef(null); + const editorRef = useRef(null); + const [editorContent, setEditorContent] = useState(initialContent); + const [outputFormat, setOutputFormat] = useState('html'); + + // Initialize the editor + useEffect(() => { + if (editorDivRef.current && !editorRef.current) { + const options = { + initialContent: editorContent, + // THIS IS THE KEY CHANGE: Use onContentChanged event in options + // This callback will be triggered by RoosterJs whenever content changes + onContentChanged: (event) => { + // event is of type ContentChangedEvent (though we don't declare type in JS) + const currentHtml = editorRef.current?.getContent() || ''; + setEditorContent(currentHtml); + if (onChange) { + onChange(currentHtml); // Call parent's onChange if provided + } + }, + }; + editorRef.current = createEditor(editorDivRef.current, options); + + // Cleanup function to dispose of the editor when component unmounts + return () => { + if (editorRef.current) { + editorRef.current.dispose(); + editorRef.current = null; + } + }; + } + }, []); // Empty dependency array means this runs once on mount + + // Function to execute editor commands + const executeCommand = useCallback((command, value) => { + if (editorRef.current) { + editorRef.current.focus(); + editorRef.current.executeEdit(command, value); + } + }, []); + + // Function to set content programmatically + const setContentProgrammatically = useCallback((newContent) => { + if (editorRef.current) { + editorRef.current.setContent(newContent); + setEditorContent(newContent); + if (onChange) { + onChange(newContent); + } + } + }, [onChange]); + + const handleFormatChange = (e) => { + setOutputFormat(e.target.value); + }; + + return ( +
+ {/* RoosterJs Custom Editor with Ant Design */} + + + + + + + + + +
+ + Show HTML Output + Show Plain Text Output + +
+ +
+
+
+ + {/* + Current Editor Content (from React state, output as {outputFormat.toUpperCase()}): + + */} + + +
+ );}; + +export default RoosterEditor; diff --git a/src/components/RoosterEditor/options/defaultDomToModelOption.ts b/src/components/RoosterEditor/options/defaultDomToModelOption.ts new file mode 100644 index 0000000..0e26c3a --- /dev/null +++ b/src/components/RoosterEditor/options/defaultDomToModelOption.ts @@ -0,0 +1,8 @@ +import { demoUndeletableAnchorParser } from './demoUndeletableAnchorParser'; +import { DomToModelOption } from 'roosterjs-content-model-types'; + +export const defaultDomToModelOption: DomToModelOption = { + additionalFormatParsers: { + link: [demoUndeletableAnchorParser], + }, +}; diff --git a/src/components/RoosterEditor/options/demoUndeletableAnchorParser.ts b/src/components/RoosterEditor/options/demoUndeletableAnchorParser.ts new file mode 100644 index 0000000..1e92016 --- /dev/null +++ b/src/components/RoosterEditor/options/demoUndeletableAnchorParser.ts @@ -0,0 +1,13 @@ +import { FormatParser, UndeletableFormat } from 'roosterjs-content-model-types'; + +export const DemoUndeletableName = 'DemoUndeletable'; + +export function undeletableLinkChecker(a: HTMLAnchorElement): boolean { + return a.getAttribute('name') == DemoUndeletableName; +} + +export const demoUndeletableAnchorParser: FormatParser = (format, element) => { + if (undeletableLinkChecker(element as HTMLAnchorElement)) { + format.undeletable = true; + } +}; diff --git a/src/components/RoosterEditor/plugins/FormatPainterPlugin.ts b/src/components/RoosterEditor/plugins/FormatPainterPlugin.ts new file mode 100644 index 0000000..8c2798a --- /dev/null +++ b/src/components/RoosterEditor/plugins/FormatPainterPlugin.ts @@ -0,0 +1,91 @@ +import { applySegmentFormat, getFormatState } from 'roosterjs-content-model-api'; +import { + ContentModelSegmentFormat, + EditorPlugin, + IEditor, + PluginEvent, +} from 'roosterjs-content-model-types'; + +const FORMATPAINTERCURSOR_SVG = require('./formatpaintercursor.svg'); +const FORMATPAINTERCURSOR_STYLE = `cursor: url("${FORMATPAINTERCURSOR_SVG}") 8.5 16, auto`; +const FORMAT_PAINTER_STYLE_KEY = '_FormatPainter'; + +/** + * Format painter handler works together with a format painter button tot let implement format painter functioinality + */ +export interface FormatPainterHandler { + /** + * Let editor enter format painter state + */ + startFormatPainter(): void; +} + +/** + * Format painter plugin helps implement format painter functionality. + * To use this plugin, you need a button to let editor enter format painter state by calling formatPainterPlugin.startFormatPainter(), + * then this plugin will handle the rest work. + */ +export class FormatPainterPlugin implements EditorPlugin, FormatPainterHandler { + private editor: IEditor | null = null; + private painterFormat: ContentModelSegmentFormat | null = null; + + getName() { + return 'FormatPainter'; + } + + initialize(editor: IEditor) { + this.editor = editor; + } + + dispose() { + this.editor = null; + } + + onPluginEvent(event: PluginEvent) { + if (this.editor && event.eventType == 'mouseUp') { + if (this.painterFormat) { + applySegmentFormat(this.editor, this.painterFormat); + + this.setFormatPainterCursor(null); + } + } + } + + private setFormatPainterCursor(format: ContentModelSegmentFormat | null) { + this.painterFormat = format; + + this.editor?.setEditorStyle( + FORMAT_PAINTER_STYLE_KEY, + this.painterFormat ? FORMATPAINTERCURSOR_STYLE : null + ); + } + + startFormatPainter() { + if (this.editor) { + const format = getSegmentFormat(this.editor); + + this.setFormatPainterCursor(format); + } + } +} + +function getSegmentFormat(editor: IEditor): ContentModelSegmentFormat { + const formatState = getFormatState(editor); + + return { + backgroundColor: formatState.backgroundColor, + fontFamily: formatState.fontName, + fontSize: formatState.fontSize, + fontWeight: formatState.isBold ? 'bold' : 'normal', + italic: formatState.isItalic, + letterSpacing: formatState.letterSpacing, + strikethrough: formatState.isStrikeThrough, + superOrSubScriptSequence: formatState.isSubscript + ? 'sub' + : formatState.isSuperscript + ? 'super' + : '', + textColor: formatState.textColor, + underline: formatState.isUnderline, + }; +} diff --git a/src/components/RoosterEditor/plugins/SampleEntityPlugin.ts b/src/components/RoosterEditor/plugins/SampleEntityPlugin.ts new file mode 100644 index 0000000..84a559e --- /dev/null +++ b/src/components/RoosterEditor/plugins/SampleEntityPlugin.ts @@ -0,0 +1,156 @@ +import { insertEntity } from 'roosterjs-content-model-api'; +import type { + EditorPlugin, + Entity, + EntityState, + IEditor, + PluginEvent, +} from 'roosterjs-content-model-types'; + +const EntityType = 'SampleEntity'; + +interface EntityMetadata { + count: number; +} + +export default class SampleEntityPlugin implements EditorPlugin { + private editor: IEditor; + private hydratedEntities: Record = {}; + + getName() { + return 'SampleEntity'; + } + + initialize(editor: IEditor) { + this.editor = editor; + } + + dispose() { + this.editor = null; + } + + onPluginEvent(event: PluginEvent) { + if (event.eventType == 'keyDown' && event.rawEvent.key == 'm' && event.rawEvent.ctrlKey) { + insertEntity(this.editor, EntityType, true /*isBlock*/, 'focus', { + contentNode: this.createEntityNode(), + initialEntityState: '{}', + }); + + event.rawEvent.preventDefault(); + } else if (event.eventType == 'entityOperation' && event.entity.type == EntityType) { + const entity = event.entity; + const hydratedEntity = this.hydratedEntities[entity.id]; + + switch (event.operation) { + case 'newEntity': + hydratedEntity?.dehydrate(); + this.hydratedEntities[entity.id] = new HydratedEntity(entity, this.onClick); + + break; + + case 'removeFromEnd': + case 'removeFromStart': + case 'overwrite': + case 'replaceTemporaryContent': + hydratedEntity?.dehydrate(); + + break; + + case 'updateEntityState': + if (event.state) { + setMetadata(event.entity.wrapper, JSON.parse(event.state)); + hydratedEntity?.update(); + } + + break; + + case 'beforeFormat': + const span = entity.wrapper.querySelector('span'); + + if (span && event.formattableRoots) { + event.formattableRoots.push({ + element: span, + }); + } + break; + } + } + } + + private onClick = (state: EntityState) => { + this.editor.takeSnapshot(state); + }; + + private createEntityNode() { + const div = document.createElement('div'); + + return div; + } +} + +class HydratedEntity { + constructor(private entity: Entity, private onClick: (entityState: EntityState) => void) { + const containerDiv = entity.wrapper.querySelector('div'); + const span = document.createElement('span'); + const button = document.createElement('button'); + + containerDiv.appendChild(span); + containerDiv.appendChild(button); + + button.textContent = 'Test entity'; + button.addEventListener('click', this.onClickEntity); + + this.update(); + } + + update(increase: number = 0) { + const metadata = getMetadata(this.entity.wrapper); + const count = (metadata?.count || 0) + increase; + + setMetadata(this.entity.wrapper, { + count, + }); + + this.entity.wrapper.querySelector('span').textContent = 'Count: ' + count; + } + + dehydrate() { + const containerDiv = this.entity.wrapper.querySelector('div'); + const button = containerDiv.querySelector('button'); + + if (button) { + button.removeEventListener('click', this.onClickEntity); + containerDiv.removeChild(button); + } + } + + private onClickEntity = (e: MouseEvent) => { + this.update(1); + this.onClick({ + id: this.entity.id, + type: this.entity.type, + state: this.entity.wrapper.dataset.editingInfo, + }); + }; +} + +const MetadataDataSetName = 'editingInfo'; + +function getMetadata(element: HTMLElement): T | null { + const str = element.dataset[MetadataDataSetName]; + let obj: any; + + try { + obj = str ? JSON.parse(str) : null; + } catch {} + + if (typeof obj !== 'undefined') { + return obj as T; + } else { + return null; + } +} + +function setMetadata(element: HTMLElement, metadata: T) { + element.dataset[MetadataDataSetName] = JSON.stringify(metadata); +} diff --git a/src/components/RoosterEditor/plugins/SamplePickerPlugin.tsx b/src/components/RoosterEditor/plugins/SamplePickerPlugin.tsx new file mode 100644 index 0000000..6df1ff3 --- /dev/null +++ b/src/components/RoosterEditor/plugins/SamplePickerPlugin.tsx @@ -0,0 +1,206 @@ +import * as React from 'react'; +import { Callout } from '@fluentui/react/lib/Callout'; +import { DOMInsertPoint } from 'roosterjs-content-model-types'; +import { IContextualMenuItem } from '@fluentui/react/lib/ContextualMenu'; +import { mergeStyles } from '@fluentui/react/lib/Styling'; +import { ReactEditorPlugin, UIUtilities } from 'roosterjs-react'; +import { + PickerDirection, + PickerHandler, + PickerHelper, + PickerPlugin, + PickerSelectionChangMode, +} from 'roosterjs-content-model-plugins'; +import { + createContentModelDocument, + createEntity, + createParagraph, + getDOMInsertPointRect, +} from 'roosterjs-content-model-dom'; + +const itemStyle = mergeStyles({ + height: '20px', + margin: '4px', + padding: '4px', + minWidth: '200px', +}); + +const selectedItemStyle = mergeStyles({ + backgroundColor: 'blue', + color: 'white', + fontWeight: 'bold', +}); + +export class SamplePickerPlugin extends PickerPlugin implements ReactEditorPlugin { + private pickerHandler: SamplePickerHandler; + + constructor() { + const pickerHandler = new SamplePickerHandler(); + super('@', pickerHandler); + + this.pickerHandler = pickerHandler; + } + + setUIUtilities(uiUtilities: UIUtilities): void { + this.pickerHandler.setUIUtilities(uiUtilities); + } +} + +class SamplePickerHandler implements PickerHandler { + private uiUtilities: UIUtilities; + private index = 0; + private ref: IPickerMenu | null = null; + private queryString: string; + private items: IContextualMenuItem[] = []; + private onClose: (() => void) | null = null; + private helper: PickerHelper | null = null; + + onInitialize(helper: PickerHelper) { + this.helper = helper; + } + + onDispose() { + this.helper = null; + } + + setUIUtilities(uiUtilities: UIUtilities): void { + this.uiUtilities = uiUtilities; + } + + onTrigger(queryString: string, pos: DOMInsertPoint): PickerDirection | null { + this.index = 0; + this.queryString = queryString; + this.items = buildItems(queryString, this.index); + + const rect = getDOMInsertPointRect(this.helper.editor.getDocument(), pos); + + if (rect) { + this.onClose = this.uiUtilities.renderComponent( + (this.ref = ref)} + items={this.items} + /> + ); + return 'vertical'; + } else { + return null; + } + } + + onClosePicker() { + this.onClose?.(); + this.onClose = null; + } + + onSelectionChanged(mode: PickerSelectionChangMode): void { + switch (mode) { + case 'first': + case 'firstInRow': + case 'previousPage': + this.index = 0; + break; + + case 'last': + case 'lastInRow': + case 'nextPage': + this.index = 4; + break; + + case 'previous': + this.index = this.index - 1; + + if (this.index < 0) { + this.index = 4; + } + + break; + + case 'next': + this.index = (this.index + 1) % 5; + break; + } + + this.items = buildItems(this.queryString, this.index); + this.ref?.setMenuItems(this.items); + } + + onSelect(): void { + const text = this.items[this.index]?.text; + + if (text) { + const span = this.helper.editor.getDocument().createElement('span'); + span.textContent = '@' + text; + span.style.textDecoration = 'underline'; + span.style.color = 'blue'; + + const entity = createEntity(span, true /*isReadonly*/, {}, 'TEST_ENTITY'); + const paragraph = createParagraph(); + const doc = createContentModelDocument(); + + paragraph.segments.push(entity); + doc.blocks.push(paragraph); + + this.helper.replaceQueryString( + doc, + { + changeSource: 'SamplePicker', + }, + true /*canUndoByBackspace*/ + ); + } + + this.onClose?.(); + this.onClose = null; + this.ref = null; + this.helper.closePicker(); + } + + onQueryStringChanged(queryString: string): void { + this.queryString = queryString; + + if (queryString.length > 100 || queryString.split(' ').length > 4) { + // Querystring is too long, so close picker + this.helper.closePicker(); + } else { + this.items = buildItems(this.queryString, this.index); + this.ref?.setMenuItems(this.items); + } + } +} + +function buildItems(queryString: string, index: number): IContextualMenuItem[] { + return [1, 2, 3, 4, 5].map((x, i) => ({ + key: 'item' + i, + text: queryString.substring(1) + ' item ' + x, + checked: i == index, + })); +} + +interface IPickerMenu { + setMenuItems: (items: IContextualMenuItem[]) => void; +} + +const PickerMenu = React.forwardRef( + ( + props: { x: number; y: number; items: IContextualMenuItem[] }, + ref: React.Ref + ) => { + const [items, setItems] = React.useState(props.items); + + React.useImperativeHandle(ref, () => ({ + setMenuItems: setItems, + })); + + return ( + + {items.map(item => ( +
+ {item.text} +
+ ))} +
+ ); + } +); diff --git a/src/components/RoosterEditor/plugins/UpdateContentPlugin.ts b/src/components/RoosterEditor/plugins/UpdateContentPlugin.ts new file mode 100644 index 0000000..c4eef96 --- /dev/null +++ b/src/components/RoosterEditor/plugins/UpdateContentPlugin.ts @@ -0,0 +1,60 @@ +import type { + ContentModelDocument, + EditorPlugin, + IEditor, + PluginEvent, +} from 'roosterjs-content-model-types'; + +/** + * A plugin to help get HTML content from editor + */ +export class UpdateContentPlugin implements EditorPlugin { + private editor: IEditor | null = null; + + /** + * Create a new instance of UpdateContentPlugin class + * @param onUpdate A callback to be invoked when update happens + */ + constructor(private onUpdate: (model: ContentModelDocument) => void) {} + + /** + * Get a friendly name of this plugin + */ + getName() { + return 'UpdateContent'; + } + + /** + * Initialize this plugin + * @param editor The editor instance + */ + initialize(editor: IEditor) { + this.editor = editor; + } + + /** + * Dispose this plugin + */ + dispose() { + this.editor = null; + } + + /** + * Handle events triggered from editor + * @param event PluginEvent object + */ + onPluginEvent(event: PluginEvent) { + switch (event.eventType) { + case 'beforeDispose': + this.update(); + break; + } + } + + update() { + if (this.editor) { + const model = this.editor.getContentModelCopy('disconnected'); + this.onUpdate(model); + } + } +} diff --git a/src/components/RoosterEditor/plugins/formatpaintercursor.svg b/src/components/RoosterEditor/plugins/formatpaintercursor.svg new file mode 100644 index 0000000..28e4c18 --- /dev/null +++ b/src/components/RoosterEditor/plugins/formatpaintercursor.svg @@ -0,0 +1,22 @@ + + Format painter + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/components/RoosterEditor/snapshot/SnapshotPane.scss b/src/components/RoosterEditor/snapshot/SnapshotPane.scss new file mode 100644 index 0000000..246c2c4 --- /dev/null +++ b/src/components/RoosterEditor/snapshot/SnapshotPane.scss @@ -0,0 +1,61 @@ +@import '../../theme/theme.scss'; + +.snapshotPane { + flex: 1 1 auto; + display: flex; + flex-direction: column; +} + +.buttons { + margin-bottom: 10px; + flex: 0 0 auto; +} + +.textarea { + flex: 1 1 auto; + resize: none; + min-height: 100px; + border-color: $primaryBorder; +} + +.input { + border-color: $primaryBorder; +} + +.snapshotList { + min-height: 100px; + max-height: 200px; + overflow: hidden auto; + border: solid 1px $primaryBorder; + + pre { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; + margin: 0; + &:hover { + background-color: #eee; + } + + &.current { + font-weight: bold; + } + + &.autoComplete { + background-color: #ff0; + } + } +} + +@media (prefers-color-scheme: dark) { + .snapshotList { + border: solid 1px $primaryBorderDark; + } + .textarea { + border-color: $primaryBorderDark; + } + .snapshotList { + border: solid 1px $primaryBorderDark; + } +} diff --git a/src/components/RoosterEditor/snapshot/SnapshotPane.tsx b/src/components/RoosterEditor/snapshot/SnapshotPane.tsx new file mode 100644 index 0000000..d503be9 --- /dev/null +++ b/src/components/RoosterEditor/snapshot/SnapshotPane.tsx @@ -0,0 +1,188 @@ +import * as React from 'react'; +import { EntityState, Snapshot, SnapshotSelection } from 'roosterjs-content-model-types'; + +const styles = require('./SnapshotPane.scss'); + +export interface SnapshotPaneProps { + onTakeSnapshot: () => Snapshot; + onRestoreSnapshot: (snapshot: Snapshot, triggerContentChangedEvent: boolean) => void; + onMove: (moveStep: number) => void; +} + +export interface SnapshotPaneState { + snapshots: Snapshot[]; + currentIndex: number; + autoCompleteIndex: number; +} + +export class SnapshotPane extends React.Component { + private html = React.createRef(); + private entityStates = React.createRef(); + private isDarkColor = React.createRef(); + private selection = React.createRef(); + private logicalRootPath = React.createRef(); + + constructor(props: SnapshotPaneProps) { + super(props); + + this.state = { + snapshots: [], + currentIndex: -1, + autoCompleteIndex: -1, + }; + } + + render() { + return ( +
+

Undo Snapshots

+
+ {this.state.snapshots.map(this.renderItem)} +
+

Selected Snapshot

+
+ {' '} + + +
+
HTML:
+