diff --git a/demo/scripts/controlsV2/mainPane/MainPane.tsx b/demo/scripts/controlsV2/mainPane/MainPane.tsx index 8ef4939beee..39e5889141b 100644 --- a/demo/scripts/controlsV2/mainPane/MainPane.tsx +++ b/demo/scripts/controlsV2/mainPane/MainPane.tsx @@ -474,6 +474,7 @@ export class MainPane extends React.Component<{}, MainPaneState> { tableMenu, imageMenu, watermarkText, + markdownOptions, } = this.state.initState; return [ pluginList.autoFormat && @@ -488,12 +489,7 @@ export class MainPane extends React.Component<{}, MainPaneState> { pluginList.shortcut && new ShortcutPlugin(), pluginList.tableEdit && new TableEditPlugin(), pluginList.watermark && new WatermarkPlugin(watermarkText), - pluginList.markdown && - new MarkdownPlugin({ - bold: true, - italic: true, - strikethrough: true, - }), + pluginList.markdown && new MarkdownPlugin(markdownOptions), pluginList.emoji && createEmojiPlugin(), pluginList.pasteOption && createPasteOptionPlugin(), pluginList.sampleEntity && new SampleEntityPlugin(), diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts b/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts index c14132c0b2f..10e226dfc06 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts @@ -38,6 +38,12 @@ const initialState: OptionState = { imageMenu: true, tableMenu: true, listMenu: true, + markdownOptions: { + bold: true, + italic: true, + strikethrough: true, + codeFormat: {}, + }, }; export class EditorOptionsPlugin extends SidePanePluginImpl { diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts b/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts index 2d0bd120033..8aef2b55f14 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts @@ -1,3 +1,4 @@ +import { MarkdownOptions } from 'roosterjs-content-model-plugins'; import type { ContentEditFeatureSettings } from 'roosterjs-editor-types'; import type { SidePaneElementProps } from '../SidePaneElement'; import type { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; @@ -35,6 +36,7 @@ export interface OptionState { tableMenu: boolean; imageMenu: boolean; watermarkText: string; + markdownOptions: MarkdownOptions; // Legacy plugin options contentEditFeatures: ContentEditFeatureSettings; diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/OptionsPane.tsx b/demo/scripts/controlsV2/sidePane/editorOptions/OptionsPane.tsx index 54fba5e2795..ee718909d96 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/OptionsPane.tsx +++ b/demo/scripts/controlsV2/sidePane/editorOptions/OptionsPane.tsx @@ -138,6 +138,7 @@ export class OptionsPane extends React.Component { listMenu: this.state.listMenu, tableMenu: this.state.tableMenu, imageMenu: this.state.imageMenu, + markdownOptions: { ...this.state.markdownOptions }, }; if (callback) { diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/codes/MarkdownCode.ts b/demo/scripts/controlsV2/sidePane/editorOptions/codes/MarkdownCode.ts new file mode 100644 index 00000000000..f0898881a1e --- /dev/null +++ b/demo/scripts/controlsV2/sidePane/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/demo/scripts/controlsV2/sidePane/editorOptions/codes/PluginsCode.ts b/demo/scripts/controlsV2/sidePane/editorOptions/codes/PluginsCode.ts index e4a9e64db78..2dcd271d0d7 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/codes/PluginsCode.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/codes/PluginsCode.ts @@ -1,6 +1,7 @@ import { CodeElement } from './CodeElement'; import { ContentEditCode } from './ContentEditCode'; import { HyperLinkCode } from './HyperLinkCode'; +import { MarkdownCode } from './MarkdownCode'; import { OptionState } from '../OptionState'; import { WatermarkCode } from './WatermarkCode'; import { @@ -11,7 +12,6 @@ import { PastePluginCode, TableEditPluginCode, ShortcutPluginCode, - MarkdownPluginCode, } from './SimplePluginCode'; export class PluginsCodeBase extends CodeElement { @@ -45,7 +45,7 @@ export class PluginsCode extends PluginsCodeBase { pluginList.tableEdit && new TableEditPluginCode(), pluginList.shortcut && new ShortcutPluginCode(), pluginList.watermark && new WatermarkCode(state.watermarkText), - pluginList.markdown && new MarkdownPluginCode(), + pluginList.markdown && new MarkdownCode(state.markdownOptions), ]); } } diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/codes/SimplePluginCode.ts b/demo/scripts/controlsV2/sidePane/editorOptions/codes/SimplePluginCode.ts index 482c1ff4e3c..4d18e51b5a4 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/codes/SimplePluginCode.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/codes/SimplePluginCode.ts @@ -51,9 +51,3 @@ export class CustomReplaceCode extends SimplePluginCode { super('CustomReplace', 'roosterjsLegacy'); } } - -export class MarkdownPluginCode extends SimplePluginCode { - constructor() { - super('MarkdownPlugin'); - } -} diff --git a/packages/roosterjs-content-model-plugins/lib/markdown/MarkdownPlugin.ts b/packages/roosterjs-content-model-plugins/lib/markdown/MarkdownPlugin.ts index 0be7a451014..5ef27d3592d 100644 --- a/packages/roosterjs-content-model-plugins/lib/markdown/MarkdownPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/markdown/MarkdownPlugin.ts @@ -1,6 +1,7 @@ import { setFormat } from './utils/setFormat'; import type { ContentChangedEvent, + ContentModelCodeFormat, EditorInputEvent, EditorPlugin, IEditor, @@ -9,18 +10,24 @@ import type { } from 'roosterjs-content-model-types'; /** + * * Options for Markdown plugin + * - strikethrough: If true text between ~ will receive strikethrough format. + * - bold: If true text between * will receive bold format. + * - italic: If true text between _ will receive italic format. + * - codeFormat: If provided, text between ` will receive code format. If equal to {}, it will set the default code format. */ export interface MarkdownOptions { strikethrough?: boolean; bold?: boolean; italic?: boolean; + codeFormat?: ContentModelCodeFormat; } /** * @internal */ -const DefaultOptions: Required = { +const DefaultOptions: Partial = { strikethrough: false, bold: false, italic: false, @@ -34,13 +41,15 @@ export class MarkdownPlugin implements EditorPlugin { private shouldBold = false; private shouldItalic = false; private shouldStrikethrough = false; + private shouldCode = false; private lastKeyTyped: string | null = null; /** * @param options An optional parameter that takes in an object of type MarkdownOptions, which includes the following properties: - * - strikethrough: If true text between ~ will receive strikethrough format. Defaults to true. - * - bold: If true text between * will receive bold format. Defaults to true. - * - italic: If true text between _ will receive italic format. Defaults to true. + * - strikethrough: If true text between ~ will receive strikethrough format. Defaults to false. + * - bold: If true text between * will receive bold format. Defaults to false. + * - italic: If true text between _ will receive italic format. Defaults to false. + * - codeFormat: If provided, text between ` will receive code format. Defaults to undefined. */ constructor(private options: MarkdownOptions = DefaultOptions) {} @@ -68,9 +77,7 @@ export class MarkdownPlugin implements EditorPlugin { */ dispose() { this.editor = null; - this.shouldBold = false; - this.shouldItalic = false; - this.shouldStrikethrough = false; + this.disableAllFeatures(); this.lastKeyTyped = null; } @@ -138,6 +145,16 @@ export class MarkdownPlugin implements EditorPlugin { } } break; + case '`': + if (this.options.codeFormat) { + if (this.shouldCode) { + setFormat(editor, '`', {} /* format */, this.options.codeFormat); + this.shouldCode = false; + } else { + this.shouldCode = true; + } + } + break; } } } @@ -147,9 +164,7 @@ export class MarkdownPlugin implements EditorPlugin { if (!event.handledByEditFeature && !rawEvent.defaultPrevented) { switch (rawEvent.key) { case 'Enter': - this.shouldBold = false; - this.shouldItalic = false; - this.shouldStrikethrough = false; + this.disableAllFeatures(); this.lastKeyTyped = null; break; case ' ': @@ -159,6 +174,8 @@ export class MarkdownPlugin implements EditorPlugin { this.shouldStrikethrough = false; } else if (this.lastKeyTyped === '_' && this.shouldItalic) { this.shouldItalic = false; + } else if (this.lastKeyTyped === '`' && this.shouldCode) { + this.shouldCode = false; } this.lastKeyTyped = null; break; @@ -177,6 +194,8 @@ export class MarkdownPlugin implements EditorPlugin { this.shouldStrikethrough = false; } else if (this.lastKeyTyped === '_' && this.shouldItalic) { this.shouldItalic = false; + } else if (this.lastKeyTyped === '`' && this.shouldCode) { + this.shouldCode = false; } this.lastKeyTyped = null; } @@ -184,9 +203,14 @@ export class MarkdownPlugin implements EditorPlugin { private handleContentChangedEvent(event: ContentChangedEvent) { if (event.source == 'Format') { - this.shouldBold = false; - this.shouldItalic = false; - this.shouldStrikethrough = false; + this.disableAllFeatures(); } } + + private disableAllFeatures() { + this.shouldBold = false; + this.shouldItalic = false; + this.shouldStrikethrough = false; + this.shouldCode = false; + } } diff --git a/packages/roosterjs-content-model-plugins/lib/markdown/utils/setFormat.ts b/packages/roosterjs-content-model-plugins/lib/markdown/utils/setFormat.ts index 9cd658045e3..b9ba6af91cd 100644 --- a/packages/roosterjs-content-model-plugins/lib/markdown/utils/setFormat.ts +++ b/packages/roosterjs-content-model-plugins/lib/markdown/utils/setFormat.ts @@ -1,12 +1,21 @@ import { getSelectedSegmentsAndParagraphs } from 'roosterjs-content-model-dom'; import { splitTextSegment } from '../../pluginUtils/splitTextSegment'; -import type { ContentModelSegmentFormat, IEditor } from 'roosterjs-content-model-types'; +import type { + ContentModelCodeFormat, + ContentModelSegmentFormat, + IEditor, +} from 'roosterjs-content-model-types'; /** * @internal */ -export function setFormat(editor: IEditor, character: string, format: ContentModelSegmentFormat) { +export function setFormat( + editor: IEditor, + character: string, + format: ContentModelSegmentFormat, + codeFormat?: ContentModelCodeFormat +) { editor.formatContentModel((model, context) => { const selectedSegmentsAndParagraphs = getSelectedSegmentsAndParagraphs( model, @@ -53,6 +62,11 @@ export function setFormat(editor: IEditor, character: string, format: ContentMod ...formattedText.format, ...format, }; + if (codeFormat) { + formattedText.code = { + format: codeFormat, + }; + } context.canUndoByBackspace = true; return true; diff --git a/packages/roosterjs-content-model-plugins/test/markdown/markdownPluginTest.ts b/packages/roosterjs-content-model-plugins/test/markdown/markdownPluginTest.ts index 619f42cea70..f9dd8c28ea0 100644 --- a/packages/roosterjs-content-model-plugins/test/markdown/markdownPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/markdown/markdownPluginTest.ts @@ -2,6 +2,7 @@ import * as setFormat from '../../lib/markdown/utils/setFormat'; import { MarkdownOptions, MarkdownPlugin } from '../../lib/markdown/MarkdownPlugin'; import { ContentChangedEvent, + ContentModelCodeFormat, ContentModelSegmentFormat, EditorInputEvent, IEditor, @@ -32,7 +33,8 @@ describe('MarkdownPlugin', () => { shouldCallTrigger: boolean, options?: MarkdownOptions, expectedChar?: string, - expectedFormat?: ContentModelSegmentFormat + expectedFormat?: ContentModelSegmentFormat, + expectedCode?: ContentModelCodeFormat ) { const plugin = new MarkdownPlugin(options); plugin.initialize(editor); @@ -40,7 +42,16 @@ describe('MarkdownPlugin', () => { events.forEach(event => plugin.onPluginEvent(event)); if (shouldCallTrigger) { - expect(setFormatSpy).toHaveBeenCalledWith(editor, expectedChar, expectedFormat); + if (expectedCode) { + expect(setFormatSpy).toHaveBeenCalledWith( + editor, + expectedChar, + expectedFormat, + expectedCode + ); + } else { + expect(setFormatSpy).toHaveBeenCalledWith(editor, expectedChar, expectedFormat); + } } else { expect(setFormatSpy).not.toHaveBeenCalled(); } @@ -178,6 +189,51 @@ describe('MarkdownPlugin', () => { ); }); + it('should trigger setFormat for code', () => { + runTest( + [ + { + rawEvent: { data: '`', inputType: 'insertText' }, + eventType: 'input', + } as EditorInputEvent, + { + rawEvent: { data: 't', inputType: 'insertText' }, + eventType: 'input', + } as EditorInputEvent, + { + rawEvent: { data: '`', inputType: 'insertText' }, + eventType: 'input', + } as EditorInputEvent, + ], + true, + { bold: true, italic: true, strikethrough: true, codeFormat: {} }, + '`', + {}, + {} + ); + }); + + it('Feature disabled - should not trigger setFormat for code', () => { + runTest( + [ + { + rawEvent: { data: '`', inputType: 'insertText' }, + eventType: 'input', + } as EditorInputEvent, + { + rawEvent: { data: 't', inputType: 'insertText' }, + eventType: 'input', + } as EditorInputEvent, + { + rawEvent: { data: '`', inputType: 'insertText' }, + eventType: 'input', + } as EditorInputEvent, + ], + false, + { bold: true, italic: true, strikethrough: true, codeFormat: undefined } + ); + }); + it('Backspace - should not trigger setFormat for bold', () => { runTest( [ diff --git a/packages/roosterjs-content-model-plugins/test/markdown/utils/setFormatTest.ts b/packages/roosterjs-content-model-plugins/test/markdown/utils/setFormatTest.ts index 15761899b41..346133c8567 100644 --- a/packages/roosterjs-content-model-plugins/test/markdown/utils/setFormatTest.ts +++ b/packages/roosterjs-content-model-plugins/test/markdown/utils/setFormatTest.ts @@ -1,5 +1,9 @@ -import { ContentModelDocument, ContentModelSegmentFormat } from 'roosterjs-content-model-types'; import { setFormat } from '../../../lib/markdown/utils/setFormat'; +import { + ContentModelCodeFormat, + ContentModelDocument, + ContentModelSegmentFormat, +} from 'roosterjs-content-model-types'; describe('setFormat', () => { function runTest( @@ -7,7 +11,8 @@ describe('setFormat', () => { char: string, format: ContentModelSegmentFormat, expectedModel: ContentModelDocument, - expectedResult: boolean + expectedResult: boolean, + code: ContentModelCodeFormat | undefined = undefined ) { const formatWithContentModelSpy = jasmine .createSpy('formatWithContentModel') @@ -27,7 +32,8 @@ describe('setFormat', () => { formatContentModel: formatWithContentModelSpy, } as any, char, - format + format, + code ); expect(formatWithContentModelSpy).toHaveBeenCalled(); @@ -214,6 +220,112 @@ describe('setFormat', () => { runTest(input, '_', { italic: true }, expectedModel, true); }); + it('should set code', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '`test`', + format: {}, + }, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + ], + format: {}, + }; + const expectedModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + code: { + format: {}, + }, + isSelected: undefined, + }, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(input, '`', {}, expectedModel, true, {}); + }); + + it('should set code with format', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '`test`', + format: {}, + }, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + ], + format: {}, + }; + const expectedModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + code: { + format: { + fontFamily: 'arial', + }, + }, + isSelected: undefined, + }, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(input, '`', {}, expectedModel, true, { fontFamily: 'arial' }); + }); + it('should set bold in multiple words', () => { const input: ContentModelDocument = { blockGroupType: 'Document',