Skip to content

Commit

Permalink
Merge pull request #2513 from microsoft/u/juliaroldi/markdown-plugin-…
Browse files Browse the repository at this point in the history
…port

[Part 1] Port Markdown feature
  • Loading branch information
juliaroldi authored Apr 1, 2024
2 parents b806329 + 3f3eba9 commit 9c41703
Show file tree
Hide file tree
Showing 10 changed files with 909 additions and 0 deletions.
7 changes: 7 additions & 0 deletions demo/scripts/controlsV2/mainPane/MainPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
import {
AutoFormatPlugin,
EditPlugin,
MarkdownPlugin,
PastePlugin,
ShortcutPlugin,
TableEditPlugin,
Expand Down Expand Up @@ -487,6 +488,12 @@ 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.emoji && createEmojiPlugin(),
pluginList.pasteOption && createPasteOptionPlugin(),
pluginList.sampleEntity && new SampleEntityPlugin(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const initialState: OptionState = {
emoji: true,
pasteOption: true,
sampleEntity: true,
markdown: true,

// Legacy plugins
contentEdit: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface NewPluginList {
emoji: boolean;
pasteOption: boolean;
sampleEntity: boolean;
markdown: boolean;
}

export interface BuildInPluginList extends LegacyPluginList, NewPluginList {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
PastePluginCode,
TableEditPluginCode,
ShortcutPluginCode,
MarkdownPluginCode,
} from './SimplePluginCode';

export class PluginsCodeBase extends CodeElement {
Expand Down Expand Up @@ -44,6 +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(),
]);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,9 @@ export class CustomReplaceCode extends SimplePluginCode {
super('CustomReplace', 'roosterjsLegacy');
}
}

export class MarkdownPluginCode extends SimplePluginCode {
constructor() {
super('MarkdownPlugin');
}
}
1 change: 1 addition & 0 deletions packages/roosterjs-content-model-plugins/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@ export { ShortcutKeyDefinition, ShortcutCommand } from './shortcut/ShortcutComma
export { ContextMenuPluginBase, ContextMenuOptions } from './contextMenuBase/ContextMenuPluginBase';
export { WatermarkPlugin } from './watermark/WatermarkPlugin';
export { WatermarkFormat } from './watermark/WatermarkFormat';
export { MarkdownPlugin, MarkdownOptions } from './markdown/MarkdownPlugin';
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { setFormat } from './utils/setFormat';
import type {
ContentChangedEvent,
EditorInputEvent,
EditorPlugin,
IEditor,
KeyDownEvent,
PluginEvent,
} from 'roosterjs-content-model-types';

/**
* Options for Markdown plugin
*/
export interface MarkdownOptions {
strikethrough?: boolean;
bold?: boolean;
italic?: boolean;
}

/**
* @internal
*/
const DefaultOptions: Required<MarkdownOptions> = {
strikethrough: false,
bold: false,
italic: false,
};

/**
* Markdown plugin handles markdown formatting, such as transforming * characters into bold text.
*/
export class MarkdownPlugin implements EditorPlugin {
private editor: IEditor | null = null;
private shouldBold = false;
private shouldItalic = false;
private shouldStrikethrough = 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.
*/
constructor(private options: MarkdownOptions = DefaultOptions) {}

/**
* Get name of this plugin
*/
getName() {
return 'Markdown';
}

/**
* The first method that editor will call to a plugin when editor is initializing.
* It will pass in the editor instance, plugin should take this chance to save the
* editor reference so that it can call to any editor method or format API later.
* @param editor The editor object
*/
initialize(editor: IEditor) {
this.editor = editor;
}

/**
* The last method that editor will call to a plugin before it is disposed.
* Plugin can take this chance to clear the reference to editor. After this method is
* called, plugin should not call to any editor method since it will result in error.
*/
dispose() {
this.editor = null;
this.shouldBold = false;
this.shouldItalic = false;
this.shouldStrikethrough = false;
this.lastKeyTyped = null;
}

/**
* Core method for a plugin. Once an event happens in editor, editor will call this
* method of each plugin to handle the event as long as the event is not handled
* exclusively by another plugin.
* @param event The event to handle:
*/
onPluginEvent(event: PluginEvent) {
if (this.editor) {
switch (event.eventType) {
case 'input':
this.handleEditorInputEvent(this.editor, event);
break;
case 'keyDown':
this.handleBackspaceEvent(event);
this.handleKeyDownEvent(event);
break;
case 'contentChanged':
this.handleContentChangedEvent(event);
break;
}
}
}

private handleEditorInputEvent(editor: IEditor, event: EditorInputEvent) {
const rawEvent = event.rawEvent;
const selection = editor.getDOMSelection();
if (
selection &&
selection.type == 'range' &&
selection.range.collapsed &&
rawEvent.inputType == 'insertText'
) {
switch (rawEvent.data) {
case '*':
if (this.options.bold) {
if (this.shouldBold) {
setFormat(editor, '*', { fontWeight: 'bold' });
this.shouldBold = false;
} else {
this.shouldBold = true;
}
}

break;
case '~':
if (this.options.strikethrough) {
if (this.shouldStrikethrough) {
setFormat(editor, '~', { strikethrough: true });
this.shouldStrikethrough = false;
} else {
this.shouldStrikethrough = true;
}
}
break;
case '_':
if (this.options.italic) {
if (this.shouldItalic) {
setFormat(editor, '_', { italic: true });
this.shouldItalic = false;
} else {
this.shouldItalic = true;
}
}
break;
}
}
}

private handleKeyDownEvent(event: KeyDownEvent) {
const rawEvent = event.rawEvent;
if (!event.handledByEditFeature && !rawEvent.defaultPrevented) {
switch (rawEvent.key) {
case 'Enter':
this.shouldBold = false;
this.shouldItalic = false;
this.shouldStrikethrough = false;
this.lastKeyTyped = null;
break;
case ' ':
if (this.lastKeyTyped === '*' && this.shouldBold) {
this.shouldBold = false;
} else if (this.lastKeyTyped === '~' && this.shouldStrikethrough) {
this.shouldStrikethrough = false;
} else if (this.lastKeyTyped === '_' && this.shouldItalic) {
this.shouldItalic = false;
}
this.lastKeyTyped = null;
break;
default:
this.lastKeyTyped = rawEvent.key;
break;
}
}
}

private handleBackspaceEvent(event: KeyDownEvent) {
if (!event.handledByEditFeature && event.rawEvent.key === 'Backspace') {
if (this.lastKeyTyped === '*' && this.shouldBold) {
this.shouldBold = false;
} else if (this.lastKeyTyped === '~' && this.shouldStrikethrough) {
this.shouldStrikethrough = false;
} else if (this.lastKeyTyped === '_' && this.shouldItalic) {
this.shouldItalic = false;
}
this.lastKeyTyped = null;
}
}

private handleContentChangedEvent(event: ContentChangedEvent) {
if (event.source == 'Format') {
this.shouldBold = false;
this.shouldItalic = false;
this.shouldStrikethrough = false;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { getSelectedSegmentsAndParagraphs } from 'roosterjs-content-model-dom';
import { splitTextSegment } from '../../pluginUtils/splitTextSegment';

import type { ContentModelSegmentFormat, IEditor } from 'roosterjs-content-model-types';

/**
* @internal
*/
export function setFormat(editor: IEditor, character: string, format: ContentModelSegmentFormat) {
editor.formatContentModel((model, context) => {
const selectedSegmentsAndParagraphs = getSelectedSegmentsAndParagraphs(
model,
false /*includeFormatHolder*/
);

if (selectedSegmentsAndParagraphs.length > 0 && selectedSegmentsAndParagraphs[0][1]) {
const marker = selectedSegmentsAndParagraphs[0][0];
context.newPendingFormat = {
...marker.format,
strikethrough: !!marker.format.strikethrough,
italic: !!marker.format.italic,
fontWeight: marker.format?.fontWeight ? 'bold' : undefined,
};

const paragraph = selectedSegmentsAndParagraphs[0][1];
if (marker.segmentType == 'SelectionMarker') {
const markerIndex = paragraph.segments.indexOf(marker);
if (markerIndex > 0 && paragraph.segments[markerIndex - 1]) {
const segmentBeforeMarker = paragraph.segments[markerIndex - 1];

if (
segmentBeforeMarker.segmentType == 'Text' &&
segmentBeforeMarker.text[segmentBeforeMarker.text.length - 1] == character
) {
const textBeforeMarker = segmentBeforeMarker.text.slice(0, -1);
if (textBeforeMarker.indexOf(character) > -1) {
const lastCharIndex = segmentBeforeMarker.text.length;
const firstCharIndex = segmentBeforeMarker.text
.substring(0, lastCharIndex - 1)
.lastIndexOf(character);

const formattedText = splitTextSegment(
segmentBeforeMarker,
paragraph,
firstCharIndex,
lastCharIndex
);

formattedText.text = formattedText.text
.replace(character, '')
.slice(0, -1);
formattedText.format = {
...formattedText.format,
...format,
};

context.canUndoByBackspace = true;
return true;
}
}
}
}
}
return false;
});
}
Loading

0 comments on commit 9c41703

Please sign in to comment.