diff --git a/app/src/docs/_examples/richTextEditor/MdSerialization.example.tsx b/app/src/docs/_examples/richTextEditor/MdSerialization.example.tsx index b6bf1ccd7f..39686db9d5 100644 --- a/app/src/docs/_examples/richTextEditor/MdSerialization.example.tsx +++ b/app/src/docs/_examples/richTextEditor/MdSerialization.example.tsx @@ -39,7 +39,7 @@ const plugins = [ export default function SlateEditorBasicExample() { const [value, setValue] = useState( - deserializeMd(demoData.slateMdSerializationInitialData), + () => deserializeMd(demoData.slateMdSerializationInitialData), ); const [mdContent, setMdContent] = useState(''); @@ -86,7 +86,7 @@ export default function SlateEditorBasicExample() { onValueChange={ (v) => { setMdContent(v); } } - rows={ 16 } + rows={ 22 } placeholder="Please type markdown here" /> )} diff --git a/uui-docs/src/demoData/slateMdSerializationInitialData.ts b/uui-docs/src/demoData/slateMdSerializationInitialData.ts index d18d87dd87..950fdd1925 100644 --- a/uui-docs/src/demoData/slateMdSerializationInitialData.ts +++ b/uui-docs/src/demoData/slateMdSerializationInitialData.ts @@ -1,13 +1,18 @@ export const slateMdSerializationInitialData = `### Basic layout -We support inline text styles: **bold**, _italic_, underlined, several UUI-friendly text colors: red, yellow, and green. +We support inline text styles such as **bold** and _italic_ . Additionally, we provide support for three levels of headers and hyperlinks. + Numbered lists: 1. In edit mode, we detect '1. ' and start list automatically 1. You can use 'tab' / 'shift/tab' to indent the list + Bullet lists: - Type '- ' to start the list - You can create multi-level lists with 'tab' / 'shift+tab'. Example: +- Level 1 - Level 2 + - Another item on level 2 - Level 3 + - Another item on level 3 `; diff --git a/uui-editor/package.json b/uui-editor/package.json index b31fcd0b08..d54455af94 100644 --- a/uui-editor/package.json +++ b/uui-editor/package.json @@ -45,7 +45,9 @@ "slate": "0.94.1", "slate-history": "0.93.0", "slate-hyperscript": "0.81.3", - "slate-react": "0.99.0" + "slate-react": "0.99.0", + "remark-parse": "9.0.0", + "unified": "9.2.2" }, "devDependencies": { "@types/lodash.debounce": "4.0.7", diff --git a/uui-editor/src/md-serializer/serialize.ts b/uui-editor/src/md-serializer/serialize.ts index aa7f4cd26b..8410b916c7 100644 --- a/uui-editor/src/md-serializer/serialize.ts +++ b/uui-editor/src/md-serializer/serialize.ts @@ -17,7 +17,7 @@ const isLeafNode = (node: BlockType | LeafType): node is LeafType => { const VOID_ELEMENTS: Array = ['thematic_break', 'image']; -const BREAK_TAG = '
'; +const BREAK_TAG = '
'; export function serialize( chunk: BlockType | LeafType, @@ -45,7 +45,7 @@ export function serialize( if (!isLeafNode(chunk)) { children = chunk.children - .map((c: BlockType | LeafType) => { + .map((c: BlockType | LeafType, index, all) => { const isList = !isLeafNode(c) ? (LIST_TYPES as string[]).includes(c.type || '') : false; @@ -71,10 +71,18 @@ export function serialize( ); } + const listProps = isList || selfIsList ? { + index, + length: all.length, + } : {}; + return serialize( { ...c, - parentType: type, + parent: { + type, + ...listProps, + }, }, { nodeTypes, @@ -108,7 +116,7 @@ export function serialize( if ( !ignoreParagraphNewline && (text === '' || text === '\n') - && chunk.parentType === nodeTypes.paragraph + && chunk.parent?.type === nodeTypes.paragraph ) { type = nodeTypes.paragraph; children = BREAK_TAG; @@ -185,10 +193,11 @@ export function serialize( case nodeTypes.ul_list: case nodeTypes.ol_list: - return `\n${children}`; + const newLineAfter = listDepth === 0 ? '\n' : ''; + return `${children}${newLineAfter}`; case nodeTypes.listItem: - const isOL = chunk && chunk.parentType === nodeTypes.ol_list; + const isOL = chunk && chunk.parent?.type === nodeTypes.ol_list; let spacer = ''; for (let k = 0; listDepth > k; k++) { @@ -199,10 +208,22 @@ export function serialize( spacer += ' '; } } - return `${spacer}${isOL ? '1.' : '-'} ${children}\n`; + + const isNewLine = chunk && ( + chunk.parent?.type === nodeTypes.ol_list + || chunk.parent?.type === nodeTypes.ul_list + ); + const emptyBefore = isNewLine ? '\n' : ''; + + const isLastItem = chunk.parent + && (chunk.parent.length! - 1 === chunk.parent.index) + && (chunk as BlockType).children.length === 1; + const emptyAfter = isLastItem && listDepth === 0 ? '\n' : ''; + + return `${emptyBefore}${spacer}${isOL ? '1.' : '-'} ${children}`; case nodeTypes.paragraph: - return `${children}\n`; + return `\n${children}\n`; case nodeTypes.thematic_break: return '\n---\n'; diff --git a/uui-editor/src/md-serializer/types.ts b/uui-editor/src/md-serializer/types.ts index 756367de08..9b1a54bb58 100644 --- a/uui-editor/src/md-serializer/types.ts +++ b/uui-editor/src/md-serializer/types.ts @@ -37,7 +37,11 @@ export type NodeTypes = { export interface LeafType { text: string; strikeThrough?: boolean; - parentType?: string; + parent?: { + type: string, + index?: number, + length?: number + }, ['uui-richTextEditor-bold']?: boolean; ['uui-richTextEditor-italic']?: boolean; ['uui-richTextEditor-code']?: boolean; @@ -45,7 +49,11 @@ export interface LeafType { export interface BlockType { type: string; - parentType?: string; + parent?: { + type: string, + index?: number, + length?: number + }, url?: string; caption?: Array; language?: string; diff --git a/uui-editor/src/plugins/colorPlugin/colorPlugin.tsx b/uui-editor/src/plugins/colorPlugin/colorPlugin.tsx index 6f9075a47e..7adde4fe5f 100644 --- a/uui-editor/src/plugins/colorPlugin/colorPlugin.tsx +++ b/uui-editor/src/plugins/colorPlugin/colorPlugin.tsx @@ -26,7 +26,6 @@ export const colorPlugin = () => createFontColorPlugin({ }, options: { floatingBarButton: ColorButton, - name: 'color-button', }, }); diff --git a/uui-editor/src/plugins/deserializeMdPlugin/deserializeMdPlugin.ts b/uui-editor/src/plugins/deserializeMdPlugin/deserializeMdPlugin.ts new file mode 100644 index 0000000000..e40c57a30b --- /dev/null +++ b/uui-editor/src/plugins/deserializeMdPlugin/deserializeMdPlugin.ts @@ -0,0 +1,143 @@ +import { + MARK_ITALIC, MARK_BOLD, MARK_CODE, +} from '@udecode/plate-basic-marks'; +import { + getPluginType, PlateEditor, getPluginOptions, +} from '@udecode/plate-core'; +import { ELEMENT_PARAGRAPH } from '@udecode/plate-paragraph'; +import { + RemarkTextRules, + MdastNodeType, + MdastNode, + RemarkPluginOptions, + remarkTransformText, + DeserializeMdPlugin, + KEY_DESERIALIZE_MD, + RemarkElementRule, + createDeserializeMdPlugin as createDeserializeMdRootPlugin, + remarkDefaultElementRules, +} from '@udecode/plate-serializer-md'; +import { + TDescendant, TElement, Value, +} from '@udecode/slate'; +import unified from 'unified'; +import markdown from 'remark-parse'; +import { isUrl } from '@udecode/plate-common'; + +const remarkDefaultTextRules: RemarkTextRules = { + text: {}, + emphasis: { mark: ({ editor }) => getPluginType(editor, MARK_ITALIC) }, + strong: { mark: ({ editor }) => getPluginType(editor, MARK_BOLD) }, + inlineCode: { mark: ({ editor }) => getPluginType(editor, MARK_CODE) }, +}; + +export const remarkTextTypes: MdastNodeType[] = [ + 'emphasis', + 'strong', + 'delete', + 'inlineCode', + // 'html', + 'text', +]; + +const remarkTransformNode = ( + node: MdastNode, + options: RemarkPluginOptions, +): TDescendant | TDescendant[] => { + const { type } = node; + + if (remarkTextTypes.includes(type!)) { + return remarkTransformText(node, options); + } + + return remarkTransformElement(node, options); +}; + +function remarkPlugin(options: RemarkPluginOptions) { + const compiler = (node: { children: Array }) => { + return node.children.flatMap((child) => + remarkTransformNode(child, options)); + }; + + // @ts-ignore + this.Compiler = compiler; +} + +const remarkTransformElement = ( + node: MdastNode, + options: RemarkPluginOptions, +): TElement | TElement[] => { + const { elementRules } = options; + + const { type } = node; + const elementRule = (elementRules as any)[type!]; + if (!elementRule) return []; + + return elementRule.transform(node, options); +}; + +/** + * Deserialize content from Markdown format to Slate format. + * `editor` needs + */ +export const deserializeMd = ( + editor: PlateEditor, + data: string, +) => { + const { elementRules, textRules } = getPluginOptions( + editor, + KEY_DESERIALIZE_MD, + ); + + const tree: any = unified() + .use(markdown as any) + .use(remarkPlugin, { + editor, + elementRules, + textRules, + } as unknown as RemarkPluginOptions) + .processSync(data); + + return tree.result; +}; + +// TODO: move to plate +const htmlRule: RemarkElementRule = { + transform: (node, options) => { + return { + type: getPluginType(options.editor, ELEMENT_PARAGRAPH), + children: [{ text: node.value?.replace(/(
)|()/g, '') || '' }], + }; + }, +}; + +export const createDeserializeMdPlugin = () => createDeserializeMdRootPlugin({ + then: (editor) => ({ + editor: { + insertData: { + format: 'text/plain', + query: ({ data, dataTransfer }) => { + const htmlData = dataTransfer.getData('text/html'); + if (htmlData) { + return false; + } + + // if content is simply a URL pass through to not break LinkPlugin + const { files } = dataTransfer; + if (!files?.length && isUrl(data)) { + return false; + } + return true; + }, + getFragment: ({ data }) => deserializeMd(editor, data), + }, + }, + }), + options: { + elementRules: { + ...remarkDefaultElementRules, + html: htmlRule, + } as any, + textRules: remarkDefaultTextRules, + }, +}); diff --git a/uui-editor/src/serialization.ts b/uui-editor/src/serialization.ts index f1b826796e..8805600f8d 100644 --- a/uui-editor/src/serialization.ts +++ b/uui-editor/src/serialization.ts @@ -24,11 +24,10 @@ import { italicPlugin, PARAGRAPH_TYPE, } from './plugins'; -import { createDeserializeMdPlugin, deserializeMd } from '@udecode/plate-serializer-md'; import { remarkNodeTypesMap, serialize } from './md-serializer'; import { createTempEditor, isEditorValueEmpty } from './helpers'; import { BaseEditor, Editor } from 'slate'; -import { createAutoformatPlugin } from './plugins/autoformatPlugin/autoformatPlugin'; +import { createDeserializeMdPlugin, deserializeMd } from './plugins/deserializeMdPlugin/deserializeMdPlugin'; type SerializerType = 'html' | 'md'; diff --git a/yarn.lock b/yarn.lock index fd2fb9bc5d..f1823488a0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15455,6 +15455,13 @@ remark-gfm@3.0.1: micromark-extension-gfm "^2.0.0" unified "^10.0.0" +remark-parse@9.0.0, remark-parse@^9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/remark-parse/-/remark-parse-9.0.0.tgz#4d20a299665880e4f4af5d90b7c7b8a935853640" + integrity sha512-geKatMwSzEXKHuzBNU1z676sGcDcFoChMK38TgdHJNAYfFtsfHDQG7MoJAjs6sgYMqyLduCYWDIWZIxiPeafEw== + dependencies: + mdast-util-from-markdown "^0.8.0" + remark-parse@^10.0.0: version "10.0.2" resolved "https://registry.yarnpkg.com/remark-parse/-/remark-parse-10.0.2.tgz#ca241fde8751c2158933f031a4e3efbaeb8bc262" @@ -15464,13 +15471,6 @@ remark-parse@^10.0.0: mdast-util-from-markdown "^1.0.0" unified "^10.0.0" -remark-parse@^9.0.0: - version "9.0.0" - resolved "https://registry.yarnpkg.com/remark-parse/-/remark-parse-9.0.0.tgz#4d20a299665880e4f4af5d90b7c7b8a935853640" - integrity sha512-geKatMwSzEXKHuzBNU1z676sGcDcFoChMK38TgdHJNAYfFtsfHDQG7MoJAjs6sgYMqyLduCYWDIWZIxiPeafEw== - dependencies: - mdast-util-from-markdown "^0.8.0" - remark-rehype@^10.0.0: version "10.1.0" resolved "https://registry.yarnpkg.com/remark-rehype/-/remark-rehype-10.1.0.tgz#32dc99d2034c27ecaf2e0150d22a6dcccd9a6279" @@ -17923,6 +17923,18 @@ unicode-property-aliases-ecmascript@^2.0.0: resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz#43d41e3be698bd493ef911077c9b131f827e8ccd" integrity sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w== +unified@9.2.2, unified@^9.2.2: + version "9.2.2" + resolved "https://registry.yarnpkg.com/unified/-/unified-9.2.2.tgz#67649a1abfc3ab85d2969502902775eb03146975" + integrity sha512-Sg7j110mtefBD+qunSLO1lqOEKdrwBFBrR6Qd8f4uwkhWNlbkaqwHse6e7QvD3AP/MNoJdEDLaf8OxYyoWgorQ== + dependencies: + bail "^1.0.0" + extend "^3.0.0" + is-buffer "^2.0.0" + is-plain-obj "^2.0.0" + trough "^1.0.0" + vfile "^4.0.0" + unified@^10.0.0: version "10.1.2" resolved "https://registry.yarnpkg.com/unified/-/unified-10.1.2.tgz#b1d64e55dafe1f0b98bb6c719881103ecf6c86df" @@ -17936,18 +17948,6 @@ unified@^10.0.0: trough "^2.0.0" vfile "^5.0.0" -unified@^9.2.2: - version "9.2.2" - resolved "https://registry.yarnpkg.com/unified/-/unified-9.2.2.tgz#67649a1abfc3ab85d2969502902775eb03146975" - integrity sha512-Sg7j110mtefBD+qunSLO1lqOEKdrwBFBrR6Qd8f4uwkhWNlbkaqwHse6e7QvD3AP/MNoJdEDLaf8OxYyoWgorQ== - dependencies: - bail "^1.0.0" - extend "^3.0.0" - is-buffer "^2.0.0" - is-plain-obj "^2.0.0" - trough "^1.0.0" - vfile "^4.0.0" - union-value@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.1.tgz#0b6fe7b835aecda61c6ea4d4f02c14221e109847"