diff --git a/demo/PresetDemo.tsx b/demo/PresetDemo.tsx new file mode 100644 index 00000000..420a67cf --- /dev/null +++ b/demo/PresetDemo.tsx @@ -0,0 +1,141 @@ +import React, {CSSProperties, useCallback, useEffect} from 'react'; + +import {toaster} from '@gravity-ui/uikit/toaster-singleton-react-18'; + +import {MarkupString, logger} from '../src'; +import { + MarkdownEditorMode, + MarkdownEditorPreset, + MarkdownEditorView, + useMarkdownEditor, +} from '../src/bundle'; +import type {RenderPreview} from '../src/bundle/Editor'; +import type {FileUploadHandler} from '../src/utils/upload'; +import {VERSION} from '../src/version'; + +import {WysiwygSelection} from './PMSelection'; +import {WysiwygDevTools} from './ProseMirrorDevTools'; +import {SplitModePreview} from './SplitModePreview'; +import {block} from './cn'; +import {randomDelay} from './delay'; +import {plugins} from './md-plugins'; + +import './Playground.scss'; + +const b = block('playground'); +const fileUploadHandler: FileUploadHandler = async (file) => { + console.info('[PresetDemo] Uploading file: ' + file.name); + await randomDelay(1000, 3000); + return {url: URL.createObjectURL(file)}; +}; + +export type PresetDemoProps = { + preset: MarkdownEditorPreset; + allowHTML?: boolean; + settingsVisible?: boolean; + breaks?: boolean; + linkify?: boolean; + linkifyTlds?: string | string[]; + splitModeOrientation?: 'horizontal' | 'vertical' | false; + stickyToolbar?: boolean; + height?: CSSProperties['height']; +}; + +logger.setLogger({ + metrics: console.info, + action: (data) => console.info(`Action: ${data.action}`, data), + ...console, +}); + +export const PresetDemo = React.memo((props) => { + const { + preset, + settingsVisible, + allowHTML, + breaks, + linkify, + linkifyTlds, + splitModeOrientation, + stickyToolbar, + height, + } = props; + const [editorMode, setEditorMode] = React.useState('wysiwyg'); + const [mdRaw, setMdRaw] = React.useState(''); + + const renderPreview = useCallback( + ({getValue}) => ( + + ), + [allowHTML, breaks, linkify, linkifyTlds], + ); + + const mdEditor = useMarkdownEditor({ + preset, + allowHTML, + linkify, + linkifyTlds, + breaks: breaks ?? true, + initialSplitModeEnabled: true, + initialToolbarVisible: true, + splitMode: splitModeOrientation, + renderPreview, + fileUploadHandler, + }); + + useEffect(() => { + function onChange() { + setMdRaw(mdEditor.getValue()); + } + function onChangeEditorType({mode}: {mode: MarkdownEditorMode}) { + setEditorMode(mode); + } + + mdEditor.on('change', onChange); + mdEditor.on('change-editor-mode', onChangeEditorType); + + return () => { + mdEditor.off('change', onChange); + mdEditor.off('change-editor-mode', onChangeEditorType); + }; + }, [mdEditor]); + + return ( +
+
+ Markdown Editor ({preset} preset) + {VERSION} +
+
+ +
+ + + +
+
+ +
+ +
+ {editorMode === 'wysiwyg' &&
{mdRaw}
} +
+
+ ); +}); + +PresetDemo.displayName = 'PresetDemo'; diff --git a/demo/Presets.stories.tsx b/demo/Presets.stories.tsx new file mode 100644 index 00000000..4cd567b2 --- /dev/null +++ b/demo/Presets.stories.tsx @@ -0,0 +1,58 @@ +import React from 'react'; + +// eslint-disable-next-line import/no-extraneous-dependencies +import type {Meta, StoryFn} from '@storybook/react'; + +import {PresetDemo, PresetDemoProps} from './PresetDemo'; + +export default { + title: 'Markdown Editor / Presets', +} as Meta; + +type PlaygroundStoryProps = Pick< + PresetDemoProps, + | 'settingsVisible' + | 'breaks' + | 'allowHTML' + | 'linkify' + | 'linkifyTlds' + | 'splitModeOrientation' + | 'stickyToolbar' + | 'height' +>; + +const args: Partial = { + settingsVisible: true, + allowHTML: true, + breaks: true, + linkify: true, + linkifyTlds: [], + splitModeOrientation: 'horizontal', + stickyToolbar: true, + height: 'initial', +}; + +export const Zero: StoryFn = (props) => ( + +); + +export const CommonMark: StoryFn = (props) => ( + +); + +export const Default: StoryFn = (props) => ( + +); + +export const YFM: StoryFn = (props) => ; + +export const Full: StoryFn = (props) => ( + +); + +Zero.args = args; +CommonMark.args = args; +CommonMark.storyName = 'CommonMark'; +Default.args = args; +YFM.args = args; +Full.args = args; diff --git a/src/bundle/Editor.ts b/src/bundle/Editor.ts index 4196dbda..a8cc407d 100644 --- a/src/bundle/Editor.ts +++ b/src/bundle/Editor.ts @@ -15,6 +15,7 @@ import type {FileUploadHandler} from '../utils/upload'; export type EditorMode = 'wysiwyg' | 'markup'; export type SplitMode = false | 'horizontal' | 'vertical'; +export type EditorPreset = 'zero' | 'commonmark' | 'default' | 'yfm' | 'full'; export type RenderPreview = ({ getValue, mode, @@ -71,6 +72,7 @@ export interface EditorInt readonly toolbarVisible: boolean; readonly splitModeEnabled: boolean; readonly splitMode: SplitMode; + readonly preset: EditorPreset; /** @internal used in demo for dev-tools */ readonly _wysiwygView?: PMEditorView; @@ -132,6 +134,7 @@ export type EditorOptions = Pick< prepareRawMarkup?: (value: MarkupString) => MarkupString; splitMode?: SplitMode; renderPreview?: RenderPreview; + preset: EditorPreset; }; /** @internal */ @@ -145,6 +148,7 @@ export class EditorImpl extends SafeEventEmitter implements EditorI #wysiwygEditor?: WysiwygEditor; #markupEditor?: MarkupEditor; + readonly #preset: EditorPreset; #allowHTML?: boolean; #linkify?: boolean; #linkifyTlds?: string | string[]; @@ -213,6 +217,10 @@ export class EditorImpl extends SafeEventEmitter implements EditorI return this.#splitMode; } + get preset(): EditorPreset { + return this.#preset; + } + get renderPreview(): RenderPreview | undefined { return this.#renderPreview; } @@ -231,7 +239,10 @@ export class EditorImpl extends SafeEventEmitter implements EditorI get wysiwygEditor(): WysiwygEditor { if (!this.#wysiwygEditor) { + const mdPreset: NonNullable = + this.#preset === 'zero' || this.#preset === 'commonmark' ? this.#preset : 'default'; this.#wysiwygEditor = new WysiwygEditor({ + mdPreset, initialContent: this.#markup, extensions: this.#extensions, allowHTML: this.#allowHTML, @@ -286,6 +297,7 @@ export class EditorImpl extends SafeEventEmitter implements EditorI this.#markup = opts.initialMarkup ?? ''; + this.#preset = opts.preset ?? 'full'; this.#linkify = opts.linkify; this.#linkifyTlds = opts.linkifyTlds; this.#allowHTML = opts.allowHTML; diff --git a/src/bundle/MarkdownEditorView.tsx b/src/bundle/MarkdownEditorView.tsx index a043fc35..59712eda 100644 --- a/src/bundle/MarkdownEditorView.tsx +++ b/src/bundle/MarkdownEditorView.tsx @@ -17,8 +17,18 @@ import {HorizontalDrag} from './HorizontalDrag'; import {MarkupEditorView} from './MarkupEditorView'; import {SplitModeView} from './SplitModeView'; import {WysiwygEditorView} from './WysiwygEditorView'; -import {MToolbarData, MToolbarSingleItemData, mHiddenData, mToolbarConfig} from './config/markup'; -import {WToolbarData, WToolbarItemData, wHiddenData, wToolbarConfig} from './config/wysiwyg'; +import { + MToolbarData, + MToolbarItemData, + mHiddenDataByPreset, + mToolbarConfigByPreset, +} from './config/markup'; +import { + WToolbarData, + WToolbarItemData, + wHiddenDataByPreset, + wToolbarConfigByPreset, +} from './config/wysiwyg'; import {useMarkdownEditorContext} from './context'; import {EditorSettings, EditorSettingsProps} from './settings'; import {stickyCn} from './sticky'; @@ -34,7 +44,7 @@ export type MarkdownEditorViewProps = ClassNameProps & { autofocus?: boolean; markupToolbarConfig?: MToolbarData; wysiwygToolbarConfig?: WToolbarData; - markupHiddenActionsConfig?: MToolbarSingleItemData[]; + markupHiddenActionsConfig?: MToolbarItemData[]; wysiwygHiddenActionsConfig?: WToolbarItemData[]; /** @default true */ settingsVisible?: boolean; @@ -64,10 +74,10 @@ export const MarkdownEditorView = React.forwardRef false; const isEnableFn = () => true; export type MToolbarData = ToolbarData; +export type MToolbarItemData = ToolbarItemData; export type MToolbarSingleItemData = ToolbarSingleItemData; export type MToolbarGroupData = ToolbarGroupData; export type MToolbarReactComponentData = ToolbarReactComponentData; export type MToolbarListButtonData = ToolbarListButtonData; export type MToolbarListItemData = ToolbarListItemData; +export type MToolbarButtonPopupData = ToolbarButtonPopupData; export const mHistoryGroupConfig: MToolbarGroupData = [ { @@ -90,7 +95,7 @@ export const mHistoryGroupConfig: MToolbarGroupData = [ /** Bold, Italic, Underline, Strike buttons group */ -export const mBoldGroupItem: MToolbarSingleItemData = { +export const mBoldItemData: MToolbarSingleItemData = { id: ActionName.bold, type: ToolbarDataType.SingleButton, title: i18n.bind(null, 'bold'), @@ -101,7 +106,7 @@ export const mBoldGroupItem: MToolbarSingleItemData = { isEnable: isEnableFn, }; -export const mItalicGroupItem: MToolbarSingleItemData = { +export const mItalicItemData: MToolbarSingleItemData = { id: ActionName.italic, type: ToolbarDataType.SingleButton, title: i18n.bind(null, 'italic'), @@ -112,9 +117,51 @@ export const mItalicGroupItem: MToolbarSingleItemData = { isEnable: isEnableFn, }; +export const mUnderlineItemData: MToolbarSingleItemData = { + id: ActionName.underline, + type: ToolbarDataType.SingleButton, + title: i18n.bind(null, 'underline'), + icon: icons.underline, + hotkey: f.toView(A.Underline), + exec: (e) => toggleUnderline(e.cm), + isActive: isActiveFn, + isEnable: isEnableFn, +}; + +export const mStrikethroughItemData: MToolbarSingleItemData = { + id: ActionName.strike, + type: ToolbarDataType.SingleButton, + title: i18n.bind(null, 'strike'), + icon: icons.strikethrough, + hotkey: f.toView(A.Strike), + exec: (e) => toggleStrikethrough(e.cm), + isActive: isActiveFn, + isEnable: isEnableFn, +}; + +export const mMonospaceItemData: MToolbarSingleItemData = { + id: ActionName.mono, + type: ToolbarDataType.SingleButton, + title: i18n.bind(null, 'mono'), + icon: icons.mono, + exec: (e) => toggleMonospace(e.cm), + isActive: isActiveFn, + isEnable: isEnableFn, +}; + +export const mMarkedItemData: MToolbarSingleItemData = { + id: ActionName.mark, + type: ToolbarDataType.SingleButton, + title: i18n.bind(null, 'mark'), + icon: icons.mark, + exec: (e) => toggleMarked(e.cm), + isActive: isActiveFn, + isEnable: isEnableFn, +}; + export const mBiusGroupConfig: MToolbarGroupData = [ - mBoldGroupItem, - mItalicGroupItem, + mBoldItemData, + mItalicItemData, { id: ActionName.underline, type: ToolbarDataType.SingleButton, @@ -327,6 +374,16 @@ export const mTableButton: MToolbarSingleItemData = { isEnable: isEnableFn, }; +export const mCodeblockItemData: MToolbarItemData = { + id: ActionName.code_block, + title: i18n.bind(null, 'codeblock'), + icon: icons.codeBlock, + hotkey: f.toView(A.CodeBlock), + exec: (e) => wrapToCodeBlock(e.cm), + isActive: isActiveFn, + isEnable: isEnableFn, +}; + export const mCodeListConfig: MToolbarListButtonData = { icon: icons.code, withArrow: true, @@ -341,15 +398,7 @@ export const mCodeListConfig: MToolbarListButtonData = { isActive: isActiveFn, isEnable: isEnableFn, }, - { - id: ActionName.code_block, - title: i18n.bind(null, 'codeblock'), - icon: icons.codeBlock, - hotkey: f.toView(A.CodeBlock), - exec: (e) => wrapToCodeBlock(e.cm), - isActive: isActiveFn, - isEnable: isEnableFn, - }, + mCodeblockItemData, ], }; @@ -393,6 +442,28 @@ export const mMermaidButton: MToolbarSingleItemData = { isEnable: isEnableFn, }; +export const mImagePopupData: MToolbarButtonPopupData = { + id: 'image', + type: ToolbarDataType.ButtonPopup, + icon: icons.image, + title: i18n('image'), + exec: noop, + isActive: isActiveFn, + isEnable: isEnableFn, + renderPopup: (props) => , +}; + +export const mFilePopupData: MToolbarButtonPopupData = { + id: 'file', + type: ToolbarDataType.ButtonPopup, + icon: icons.file, + title: i18n('file'), + exec: noop, + isActive: isActiveFn, + isEnable: isEnableFn, + renderPopup: (props) => , +}; + /** prepared markup toolbar config */ export const mToolbarConfig: MToolbarData = [ mHistoryGroupConfig, @@ -425,16 +496,7 @@ export const mToolbarConfig: MToolbarData = [ }, ], [ - { - id: 'image', - type: ToolbarDataType.ButtonPopup, - icon: icons.image, - title: i18n('image'), - exec: noop, - isActive: isActiveFn, - isEnable: isEnableFn, - renderPopup: (props) => , - }, + mImagePopupData, { id: 'file', type: ToolbarDataType.ButtonPopup, @@ -471,3 +533,100 @@ export const mTabsItemData: MToolbarSingleItemData = { }; export const mHiddenData = [mHruleItemData, mTabsItemData]; + +export const mToolbarConfigByPreset: Record = { + zero: [mHistoryGroupConfig], + commonmark: [ + mHistoryGroupConfig, + [mBoldItemData, mItalicItemData], + [ + {id: 'heading', type: ToolbarDataType.ListButton, ...mHeadingListConfig}, + {id: 'list', type: ToolbarDataType.ListButton, ...mListsListConfig}, + mLinkButton, + mQuoteButton, + {id: 'code', type: ToolbarDataType.ListButton, ...mCodeListConfig}, + ], + ], + default: [ + mHistoryGroupConfig, + [mBoldItemData, mItalicItemData, mStrikethroughItemData], + [ + {id: 'heading', type: ToolbarDataType.ListButton, ...mHeadingListConfig}, + {id: 'list', type: ToolbarDataType.ListButton, ...mListsListConfig}, + mLinkButton, + mQuoteButton, + {id: 'code', type: ToolbarDataType.ListButton, ...mCodeListConfig}, + ], + ], + yfm: [ + mHistoryGroupConfig, + [ + mBoldItemData, + mItalicItemData, + mUnderlineItemData, + mStrikethroughItemData, + mMonospaceItemData, + ], + [ + {id: 'heading', type: ToolbarDataType.ListButton, ...mHeadingListConfig}, + {id: 'list', type: ToolbarDataType.ListButton, ...mListsListConfig}, + mLinkButton, + mNoteButton, + mCutButton, + mQuoteButton, + {id: 'code', type: ToolbarDataType.ListButton, ...mCodeListConfig}, + ], + [mImagePopupData, mFilePopupData, mTableButton, mCheckboxButton], + ], + full: mToolbarConfig.slice(), +}; + +export const mHiddenDataByPreset: Record = { + zero: [], + commonmark: [ + ...mHeadingListConfig.data, + ...mListsListConfig.data, + mLinkButton, + mQuoteButton, + mCodeblockItemData, + mHruleItemData, + ], + default: [ + ...mHeadingListConfig.data, + ...mListsListConfig.data, + mLinkButton, + mQuoteButton, + mCodeblockItemData, + mHruleItemData, + ], + yfm: [ + ...mHeadingListConfig.data, + ...mListsListConfig.data, + mLinkButton, + mQuoteButton, + mNoteButton, + mCutButton, + mCodeblockItemData, + mCheckboxButton, + mTableButton, + mImagePopupData, + mHruleItemData, + mFilePopupData, + mTabsItemData, + ], + full: [ + ...mHeadingListConfig.data, + ...mListsListConfig.data, + mLinkButton, + mQuoteButton, + mNoteButton, + mCutButton, + mCodeblockItemData, + mCheckboxButton, + mTableButton, + mImagePopupData, + mHruleItemData, + mFilePopupData, + mTabsItemData, + ], +}; diff --git a/src/bundle/config/wysiwyg.ts b/src/bundle/config/wysiwyg.ts index cdde49e4..5a9bcb4a 100644 --- a/src/bundle/config/wysiwyg.ts +++ b/src/bundle/config/wysiwyg.ts @@ -1,7 +1,10 @@ import {ActionStorage} from 'src/core'; import {headingType, pType} from '../../extensions'; -import {SelectionContextConfig} from '../../extensions/behavior/SelectionContext'; +import type { + SelectionContextConfig, + SelectionContextItemData, +} from '../../extensions/behavior/SelectionContext'; // for typings from Math import type {} from '../../extensions/yfm/Math'; import {i18n as i18nHint} from '../../i18n/hints'; @@ -18,6 +21,7 @@ import { ToolbarListItemData, ToolbarSingleItemData, } from '../../toolbar/types'; +import type {EditorPreset} from '../Editor'; import {WToolbarColors} from '../toolbar/wysiwyg/WToolbarColors'; import {WToolbarTextSelect} from '../toolbar/wysiwyg/WToolbarTextSelect'; @@ -82,47 +86,55 @@ export const wItalicItemData: WToolbarSingleItemData = { isEnable: (e) => e.actions.italic.isEnable(), }; +export const wUnderlineItemData: WToolbarSingleItemData = { + id: ActionName.underline, + type: ToolbarDataType.SingleButton, + title: i18n.bind(null, 'underline'), + icon: icons.underline, + hotkey: f.toView(A.Underline), + exec: (e) => e.actions.underline.run(), + isActive: (e) => e.actions.underline.isActive(), + isEnable: (e) => e.actions.underline.isEnable(), +}; + +export const wStrikethroughItemData: WToolbarSingleItemData = { + id: ActionName.strike, + type: ToolbarDataType.SingleButton, + title: i18n.bind(null, 'strike'), + icon: icons.strikethrough, + hotkey: f.toView(A.Strike), + exec: (e) => e.actions.strike.run(), + isActive: (e) => e.actions.strike.isActive(), + isEnable: (e) => e.actions.strike.isEnable(), +}; + +export const wMonospaceItemData: WToolbarSingleItemData = { + id: ActionName.mono, + type: ToolbarDataType.SingleButton, + title: i18n.bind(null, 'mono'), + icon: icons.mono, + exec: (e) => e.actions.mono.run(), + isActive: (e) => e.actions.mono.isActive(), + isEnable: (e) => e.actions.mono.isEnable(), +}; + +export const wMarkedItemData: WToolbarSingleItemData = { + id: ActionName.mark, + type: ToolbarDataType.SingleButton, + title: i18n.bind(null, 'mark'), + icon: icons.mark, + exec: (e) => e.actions.mark.run(), + isActive: (e) => e.actions.mark.isActive(), + isEnable: (e) => e.actions.mark.isEnable(), +}; + export const wBiusGroupConfig: WToolbarGroupData = [ wBoldItemData, wItalicItemData, - { - id: ActionName.underline, - type: ToolbarDataType.SingleButton, - title: i18n.bind(null, 'underline'), - icon: icons.underline, - hotkey: f.toView(A.Underline), - exec: (e) => e.actions.underline.run(), - isActive: (e) => e.actions.underline.isActive(), - isEnable: (e) => e.actions.underline.isEnable(), - }, - { - id: ActionName.strike, - type: ToolbarDataType.SingleButton, - title: i18n.bind(null, 'strike'), - icon: icons.strikethrough, - hotkey: f.toView(A.Strike), - exec: (e) => e.actions.strike.run(), - isActive: (e) => e.actions.strike.isActive(), - isEnable: (e) => e.actions.strike.isEnable(), - }, - { - id: ActionName.mono, - type: ToolbarDataType.SingleButton, - title: i18n.bind(null, 'mono'), - icon: icons.mono, - exec: (e) => e.actions.mono.run(), - isActive: (e) => e.actions.mono.isActive(), - isEnable: (e) => e.actions.mono.isEnable(), - }, - { - id: ActionName.mark, - type: ToolbarDataType.SingleButton, - title: i18n.bind(null, 'mark'), - icon: icons.mark, - exec: (e) => e.actions.mark.run(), - isActive: (e) => e.actions.mark.isActive(), - isEnable: (e) => e.actions.mark.isEnable(), - }, + wUnderlineItemData, + wStrikethroughItemData, + wMonospaceItemData, + wMarkedItemData, ]; export const wTextItemData: WToolbarListButtonItemData = { @@ -499,20 +511,20 @@ export const wToolbarConfig: WToolbarData = [ [wImageItemData, wFileItemData, wTableItemData, wCheckboxItemData], ]; +const textContextItemData: SelectionContextItemData = { + id: 'text', + type: ToolbarDataType.ReactComponent, + component: WToolbarTextSelect, + width: 0, + condition: ({selection: {$from, $to}, schema}) => { + if (!$from.sameParent($to)) return false; + const {parent} = $from; + return parent.type === pType(schema) || parent.type === headingType(schema); + }, +}; + export const wSelectionMenuConfig: SelectionContextConfig = [ - [ - { - id: 'text', - type: ToolbarDataType.ReactComponent, - component: WToolbarTextSelect, - width: 0, - condition: ({selection: {$from, $to}, schema}) => { - if (!$from.sameParent($to)) return false; - const {parent} = $from; - return parent.type === pType(schema) || parent.type === headingType(schema); - }, - }, - ], + [textContextItemData], [...wBiusGroupConfig, wCodeItemData], [ { @@ -534,3 +546,138 @@ export const wMermaidItemData: WToolbarSingleItemData = { isActive: (e) => e.actions.createMermaid.isActive(), isEnable: (e) => e.actions.createMermaid.isEnable(), }; + +export const wToolbarConfigByPreset: Record = { + zero: [wHistoryGroupConfig], + commonmark: [ + wHistoryGroupConfig, + [wBoldItemData, wItalicItemData], + [ + {id: 'heading', type: ToolbarDataType.ListButton, ...wHeadingListConfig}, + {id: 'list', type: ToolbarDataType.ListButton, ...wListsListConfig}, + wLinkItemData, + wQuoteItemData, + {id: 'code', type: ToolbarDataType.ListButton, ...wCodeListConfig}, + ], + ], + default: [ + wHistoryGroupConfig, + [wBoldItemData, wItalicItemData, wStrikethroughItemData], + [ + { + id: 'heading', + type: ToolbarDataType.ListButton, + ...wHeadingListConfig, + }, + { + id: 'list', + type: ToolbarDataType.ListButton, + ...wListsListConfig, + }, + wLinkItemData, + wQuoteItemData, + { + id: 'code', + type: ToolbarDataType.ListButton, + ...wCodeListConfig, + }, + ], + ], + yfm: [ + wHistoryGroupConfig, + [ + wBoldItemData, + wItalicItemData, + wUnderlineItemData, + wStrikethroughItemData, + wMonospaceItemData, + ], + [ + { + id: 'heading', + type: ToolbarDataType.ListButton, + ...wHeadingListConfig, + }, + { + id: 'list', + type: ToolbarDataType.ListButton, + ...wListsListConfig, + }, + wLinkItemData, + wNoteItemData, + wCutItemData, + wQuoteItemData, + { + id: 'code', + type: ToolbarDataType.ListButton, + ...wCodeListConfig, + }, + ], + [wImageItemData, wFileItemData, wTableItemData, wCheckboxItemData], + ], + full: wToolbarConfig.slice(), +}; + +export const wCommandMenuConfigByPreset: Record = { + zero: [], + commonmark: [ + ...wHeadingListConfig.data, + ...wListsListConfig.data, + wLinkItemData, + wQuoteItemData, + wCodeBlockItemData, + wHruleItemData, + ], + default: [ + ...wHeadingListConfig.data, + ...wListsListConfig.data, + wLinkItemData, + wQuoteItemData, + wCodeBlockItemData, + wHruleItemData, + ], + yfm: [ + ...wHeadingListConfig.data, + ...wListsListConfig.data, + wLinkItemData, + wQuoteItemData, + wNoteItemData, + wCutItemData, + wCodeBlockItemData, + wCheckboxItemData, + wTableItemData, + wImageItemData, + wHruleItemData, + wFileItemData, + wTabsItemData, + ], + full: wCommandMenuConfig.slice(), +}; + +export const wHiddenDataByPreset: Record = { + zero: wCommandMenuConfigByPreset.zero.slice(), + commonmark: wCommandMenuConfigByPreset.commonmark.slice(), + default: wCommandMenuConfigByPreset.default.slice(), + yfm: wCommandMenuConfigByPreset.yfm.slice(), + full: wCommandMenuConfigByPreset.full.slice(), +}; + +export const wSelectionMenuConfigByPreset: Record = { + zero: [], + commonmark: [ + [textContextItemData], + [wBoldItemData, wItalicItemData, wCodeItemData], + [wLinkItemData], + ], + default: [ + [textContextItemData], + [wBoldItemData, wItalicItemData, wStrikethroughItemData, wCodeItemData], + [wLinkItemData], + ], + yfm: [ + [textContextItemData], + [wBoldItemData, wItalicItemData, wStrikethroughItemData, wMonospaceItemData, wCodeItemData], + [wLinkItemData], + ], + full: wSelectionMenuConfig.slice(), +}; diff --git a/src/bundle/index.ts b/src/bundle/index.ts index 42ce3170..57104efd 100644 --- a/src/bundle/index.ts +++ b/src/bundle/index.ts @@ -1,5 +1,11 @@ export type {ExtensionsOptions} from './wysiwyg-preset'; -export type {Editor, EditorMode as MarkdownEditorMode, RenderPreview, SplitMode} from './Editor'; +export type { + Editor, + EditorMode as MarkdownEditorMode, + EditorPreset as MarkdownEditorPreset, + RenderPreview, + SplitMode, +} from './Editor'; export * from './context'; export * from './useMarkdownEditor'; export * from './MarkdownEditorView'; diff --git a/src/bundle/useMarkdownEditor.ts b/src/bundle/useMarkdownEditor.ts index 4c233a2e..875ca6e2 100644 --- a/src/bundle/useMarkdownEditor.ts +++ b/src/bundle/useMarkdownEditor.ts @@ -4,13 +4,19 @@ import {Extension} from '../core'; import {ReactRenderStorage} from '../extensions'; import {logger} from '../logger'; -import {Editor, EditorImpl, EditorInt, EditorMode, EditorOptions} from './Editor'; +import {Editor, EditorImpl, EditorInt, EditorMode, EditorOptions, EditorPreset} from './Editor'; import {BundlePreset, ExtensionsOptions} from './wysiwyg-preset'; export type UseMarkdownEditorProps = Omit< EditorOptions, - 'extensions' | 'renderStorage' + 'extensions' | 'renderStorage' | 'preset' > & { + /** + * A set of plug-in extensions. + * + * @default 'full' + */ + preset?: EditorPreset; breaks?: boolean; /** Used only first value. Сhanging the value will not lead to anything */ extensionOptions?: Omit & T; @@ -24,10 +30,12 @@ export function useMarkdownEditor( ): Editor { const editor = useMemo( () => { + const preset: EditorPreset = props.preset ?? 'full'; const renderStorage = new ReactRenderStorage(); const extensions: Extension = (builder) => { builder.use(BundlePreset, { ...props.extensionOptions, + preset, reactRenderer: renderStorage, onCancel: () => { editor.emit('cancel', null); @@ -46,7 +54,7 @@ export function useMarkdownEditor( builder.use(props.extraExtensions, props.extensionOptions); } }; - return new EditorImpl({...props, extensions, renderStorage}); + return new EditorImpl({...props, extensions, renderStorage, preset}); }, deps.concat( props.allowHTML, diff --git a/src/bundle/wysiwyg-preset.ts b/src/bundle/wysiwyg-preset.ts index 93a769e1..f246d756 100644 --- a/src/bundle/wysiwyg-preset.ts +++ b/src/bundle/wysiwyg-preset.ts @@ -5,17 +5,23 @@ import {BehaviorPreset, BehaviorPresetOptions} from '../extensions/behavior'; import {EditorModeKeymap, EditorModeKeymapOptions} from '../extensions/behavior/EditorModeKeymap'; import {BaseNode, YfmHeadingAttr, YfmNoteNode} from '../extensions/specs'; import {i18n as i18nPlaceholder} from '../i18n/placeholder'; +import {CommonMarkPreset, CommonMarkPresetOptions} from '../presets/commonmark'; +import {DefaultPreset, DefaultPresetOptions} from '../presets/default'; import {FullPreset, FullPresetOptions} from '../presets/full'; +import {YfmPreset, YfmPresetOptions} from '../presets/yfm'; +import {ZeroPreset, ZeroPresetOptions} from '../presets/zero'; import {Action as A, formatter as f} from '../shortcuts'; import type {FileUploadHandler} from '../utils/upload'; -import {wCommandMenuConfig, wSelectionMenuConfig} from './config/wysiwyg'; +import {EditorPreset} from './Editor'; +import {wCommandMenuConfigByPreset, wSelectionMenuConfigByPreset} from './config/wysiwyg'; import {emojiDefs} from './emoji'; export type ExtensionsOptions = BehaviorPresetOptions & FullPresetOptions; export type BundlePresetOptions = ExtensionsOptions & EditorModeKeymapOptions & { + preset: EditorPreset; mdBreaks?: boolean; fileUploadHandler?: FileUploadHandler; /** @@ -31,12 +37,14 @@ export const BundlePreset: ExtensionAuto = (builder, opts) color: 'var(--g-color-line-brand)', width: 2, }; - const options = { + + const zeroOptions: BehaviorPresetOptions & ZeroPresetOptions = { ...opts, cursor: {dropOptions: dropCursor}, clipboard: {pasteFileHandler: opts.fileUploadHandler, ...opts.clipboard}, - selectionContext: {config: wSelectionMenuConfig, ...opts.selectionContext}, - commandMenu: {actions: wCommandMenuConfig, ...opts.commandMenu}, + selectionContext: {config: wSelectionMenuConfigByPreset.zero, ...opts.selectionContext}, + commandMenu: {actions: wCommandMenuConfigByPreset.zero, ...opts.commandMenu}, + history: {undoKey: f.toPM(A.Undo), redoKey: f.toPM(A.Redo), ...opts.history}, baseSchema: { paragraphKey: f.toPM(A.Text), paragraphPlaceholder: (node: Node, parent?: Node | null) => { @@ -46,15 +54,20 @@ export const BundlePreset: ExtensionAuto = (builder, opts) }, ...opts.baseSchema, }, + }; + const commonMarkOptions: BehaviorPresetOptions & CommonMarkPresetOptions = { + ...zeroOptions, + selectionContext: { + config: wSelectionMenuConfigByPreset.commonmark, + ...opts.selectionContext, + }, + commandMenu: {actions: wCommandMenuConfigByPreset.commonmark, ...opts.commandMenu}, breaks: { preferredBreak: (opts.mdBreaks ? 'soft' : 'hard') as 'soft' | 'hard', ...opts.breaks, }, - history: {undoKey: f.toPM(A.Undo), redoKey: f.toPM(A.Redo), ...opts.history}, bold: {boldKey: f.toPM(A.Bold), ...opts.bold}, italic: {italicKey: f.toPM(A.Italic), ...opts.italic}, - strike: {strikeKey: f.toPM(A.Strike), ...opts.strike}, - underline: {underlineKey: f.toPM(A.Underline), ...opts.underline}, code: {codeKey: f.toPM(A.Code), ...opts.code}, codeBlock: { codeBlockKey: f.toPM(A.CodeBlock), @@ -68,6 +81,18 @@ export const BundlePreset: ExtensionAuto = (builder, opts) ulInputRules: {plus: false}, ...opts.lists, }, + }; + const defaultOptions: BehaviorPresetOptions & DefaultPresetOptions = { + ...commonMarkOptions, + selectionContext: {config: wSelectionMenuConfigByPreset.default, ...opts.selectionContext}, + commandMenu: {actions: wCommandMenuConfigByPreset.default, ...opts.commandMenu}, + strike: {strikeKey: f.toPM(A.Strike), ...opts.strike}, + }; + const yfmOptions: BehaviorPresetOptions & YfmPresetOptions = { + ...defaultOptions, + selectionContext: {config: wSelectionMenuConfigByPreset.yfm, ...opts.selectionContext}, + commandMenu: {actions: wCommandMenuConfigByPreset.yfm, ...opts.commandMenu}, + underline: {underlineKey: f.toPM(A.Underline), ...opts.underline}, imgSize: { imageUploadHandler: opts.fileUploadHandler, needToSetDimensionsForUploadedImages: opts.needToSetDimensionsForUploadedImages, @@ -106,22 +131,21 @@ export const BundlePreset: ExtensionAuto = (builder, opts) `${i18nPlaceholder('heading')} ${node.attrs[YfmHeadingAttr.Level]}`, // todo: remove attrs import ...opts.yfmHeading, }, - emoji: {defs: emojiDefs, ...opts.emoji}, placeholder: { [YfmNoteNode.NoteContent]: () => i18nPlaceholder('note_content'), }, }; + const fullOptions: BehaviorPresetOptions & FullPresetOptions = { + ...yfmOptions, + selectionContext: {config: wSelectionMenuConfigByPreset.full, ...opts.selectionContext}, + commandMenu: {actions: wCommandMenuConfigByPreset.full, ...opts.commandMenu}, + emoji: {defs: emojiDefs, ...opts.emoji}, + }; - builder.use(BehaviorPreset, options).use(FullPreset, options); - - const ignoreActions = [ - A.Undo, - A.Redo, - + const zeroIgnoreActions = [A.Undo, A.Redo]; + const commonMarkIgnoreActions = zeroIgnoreActions.concat( A.Bold, A.Italic, - A.Underline, - A.Strike, A.Code, A.Link, @@ -138,10 +162,45 @@ export const BundlePreset: ExtensionAuto = (builder, opts) A.Quote, A.CodeBlock, + ); + const defaultIgnoreActions = commonMarkIgnoreActions.concat(A.Strike); + const yfmIgnoreActions = defaultIgnoreActions.concat( + A.Underline, A.Note, A.Cut, - ]; + ); + const fullIgnoreActions = yfmIgnoreActions.concat(); + + let ignoreActions; + + switch (opts.preset) { + case 'zero': { + ignoreActions = zeroIgnoreActions; + builder.use(BehaviorPreset, zeroOptions).use(ZeroPreset, zeroOptions); + break; + } + case 'commonmark': { + ignoreActions = commonMarkIgnoreActions; + builder.use(BehaviorPreset, commonMarkOptions).use(CommonMarkPreset, commonMarkOptions); + break; + } + case 'default': { + ignoreActions = defaultIgnoreActions; + builder.use(BehaviorPreset, defaultOptions).use(DefaultPreset, defaultOptions); + break; + } + case 'yfm': { + ignoreActions = yfmIgnoreActions; + builder.use(BehaviorPreset, yfmOptions).use(YfmPreset, yfmOptions); + break; + } + default: { + ignoreActions = fullIgnoreActions; + builder.use(BehaviorPreset, fullOptions).use(FullPreset, fullOptions); + break; + } + } const ignoreKeysList = opts.ignoreKeysList?.slice() ?? []; for (const action of ignoreActions) { diff --git a/src/core/Editor.ts b/src/core/Editor.ts index 8d6ee457..22c8ff1d 100644 --- a/src/core/Editor.ts +++ b/src/core/Editor.ts @@ -1,3 +1,4 @@ +import type {PresetName} from 'markdown-it'; import {EditorState} from 'prosemirror-state'; import {EditorView} from 'prosemirror-view'; @@ -20,6 +21,8 @@ export type WysiwygEditorOptions = { /** markdown markup */ initialContent?: string; extensions?: Extension; + /** @default 'default' */ + mdPreset?: PresetName; allowHTML?: boolean; linkify?: boolean; linkifyTlds?: string | string[]; @@ -62,6 +65,7 @@ export class WysiwygEditor implements CommonEditor, ActionStorage { initialContent = '', extensions = () => {}, allowHTML, + mdPreset, linkify, linkifyTlds, onChange, @@ -78,7 +82,7 @@ export class WysiwygEditor implements CommonEditor, ActionStorage { actions, } = ExtensionsManager.process(extensions, { // "breaks" option only affects the renderer, but not the parser - mdOpts: {html: allowHTML, linkify, breaks: true}, + mdOpts: {html: allowHTML, linkify, breaks: true, preset: mdPreset}, linkifyTlds, }); diff --git a/src/core/ExtensionsManager.ts b/src/core/ExtensionsManager.ts index b69dad85..e9c1e848 100644 --- a/src/core/ExtensionsManager.ts +++ b/src/core/ExtensionsManager.ts @@ -1,4 +1,4 @@ -import MarkdownIt from 'markdown-it'; +import MarkdownIt, {PresetName} from 'markdown-it'; import type {Plugin} from 'prosemirror-state'; import {ActionsManager} from './ActionsManager'; @@ -22,7 +22,7 @@ type ExtensionsManagerParams = { }; type ExtensionsManagerOptions = { - mdOpts?: MarkdownIt.Options; + mdOpts?: MarkdownIt.Options & {preset?: PresetName}; linkifyTlds?: string | string[]; }; @@ -53,8 +53,9 @@ export class ExtensionsManager { constructor({extensions, options = {}}: ExtensionsManagerParams) { this.#extensions = extensions; - this.#mdForMarkup = new MarkdownIt(options.mdOpts ?? {}); - this.#mdForText = new MarkdownIt(options.mdOpts ?? {}); + const mdPreset: PresetName = options.mdOpts?.preset ?? 'default'; + this.#mdForMarkup = new MarkdownIt(mdPreset, options.mdOpts ?? {}); + this.#mdForText = new MarkdownIt(mdPreset, options.mdOpts ?? {}); if (options.linkifyTlds) { this.#mdForMarkup.linkify.tlds(options.linkifyTlds, true); diff --git a/src/extensions/behavior/SelectionContext/index.ts b/src/extensions/behavior/SelectionContext/index.ts index 72fd90b5..25d18fde 100644 --- a/src/extensions/behavior/SelectionContext/index.ts +++ b/src/extensions/behavior/SelectionContext/index.ts @@ -7,7 +7,10 @@ import {isCodeBlock} from '../../../utils/nodes'; import {ContextConfig, TooltipView} from './tooltip'; -export type {ContextConfig as SelectionContextConfig} from './tooltip'; +export type { + ContextConfig as SelectionContextConfig, + ContextGroupItemData as SelectionContextItemData, +} from './tooltip'; export type SelectionContextOptions = { config?: ContextConfig;