diff --git a/projects/demo/src/app/app.pages.ts b/projects/demo/src/app/app.pages.ts
index 44503d929..4a3377258 100644
--- a/projects/demo/src/app/app.pages.ts
+++ b/projects/demo/src/app/app.pages.ts
@@ -150,9 +150,21 @@ export const DEMO_PAGES: TuiDocPages = [
'editor, processing, content, cleanup html, wysiwyg, редактор, текст, html, rich, text',
route: `/${TuiDemoPath.ProcessingCleanupHtml}`,
},
+ ],
+ },
+ {
+ section: 'Documentation',
+ title: 'Markdown',
+ subPages: [
+ {
+ section: 'Documentation',
+ title: 'Extension',
+ keywords: 'editor, markdown, wysiwyg, редактор, текст, html, rich, text',
+ route: `/${TuiDemoPath.ProcessingMarkdownExtension}`,
+ },
{
section: 'Documentation',
- title: 'Markdown',
+ title: 'Custom parsing',
keywords: 'editor, markdown, wysiwyg, редактор, текст, html, rich, text',
route: `/${TuiDemoPath.ProcessingMarkdown}`,
},
diff --git a/projects/demo/src/app/app.routes.ts b/projects/demo/src/app/app.routes.ts
index 727ef485b..be115a69a 100644
--- a/projects/demo/src/app/app.routes.ts
+++ b/projects/demo/src/app/app.routes.ts
@@ -104,6 +104,11 @@ export const routes: Routes = [
loadComponent: async () => import('./pages/processing/markdown'),
title: 'Editor — Markdown',
}),
+ route({
+ path: TuiDemoPath.ProcessingMarkdownExtension,
+ loadComponent: async () => import('./pages/processing/markdown-extension'),
+ title: 'Editor — Markdown',
+ }),
route({
path: TuiDemoPath.HighlightCode,
loadComponent: async () => import('./pages/highlight/code'),
diff --git a/projects/demo/src/app/constants/demo-path.ts b/projects/demo/src/app/constants/demo-path.ts
index 26db977dd..7e8ad1f2f 100644
--- a/projects/demo/src/app/constants/demo-path.ts
+++ b/projects/demo/src/app/constants/demo-path.ts
@@ -23,6 +23,7 @@ export const TuiDemoPath = {
ProcessingCleanupHtml: 'processing/cleanup-html',
ProcessingLegacyHtml: 'processing/legacy-html',
ProcessingMarkdown: 'processing/markdown',
+ ProcessingMarkdownExtension: 'processing/markdown-extension',
Stackblitz: 'stackblitz',
StarterKit: 'starter-kit',
UploadFiles: 'upload-files',
diff --git a/projects/demo/src/app/pages/processing/markdown-extension/examples/1/index.html b/projects/demo/src/app/pages/processing/markdown-extension/examples/1/index.html
new file mode 100644
index 000000000..ec41d53eb
--- /dev/null
+++ b/projects/demo/src/app/pages/processing/markdown-extension/examples/1/index.html
@@ -0,0 +1,15 @@
+
+ Placeholder
+
+
+
+ Markdown
+
diff --git a/projects/demo/src/app/pages/processing/markdown-extension/examples/1/index.ts b/projects/demo/src/app/pages/processing/markdown-extension/examples/1/index.ts
new file mode 100644
index 000000000..3ec1960e8
--- /dev/null
+++ b/projects/demo/src/app/pages/processing/markdown-extension/examples/1/index.ts
@@ -0,0 +1,86 @@
+import type {OnInit} from '@angular/core';
+import {ChangeDetectionStrategy, Component, inject, ViewChild} from '@angular/core';
+import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms';
+import {TuiDestroyService} from '@taiga-ui/cdk';
+import {TuiTextareaModule} from '@taiga-ui/kit';
+import {
+ TUI_EDITOR_EXTENSIONS,
+ TuiEditorComponent,
+ TuiEditorTool,
+} from '@tinkoff/tui-editor';
+import type {Editor} from '@tiptap/core';
+import {debounceTime, Subject, takeUntil} from 'rxjs';
+
+const markdown = `# h1 Heading 😎
+
+## h2 Heading
+
+### h3 Heading
+
+#### h4 Heading
+
+##### h5 Heading
+
+###### h6 Heading
+
+----
+
+![image info](./assets/icons/logo.svg)
+`;
+
+@Component({
+ standalone: true,
+ imports: [TuiEditorComponent, ReactiveFormsModule, TuiTextareaModule, FormsModule],
+ templateUrl: './index.html',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ providers: [
+ {
+ provide: TUI_EDITOR_EXTENSIONS,
+ useValue: [
+ import('@tiptap/starter-kit').then(({StarterKit}) => StarterKit),
+ import('@tiptap/extension-image').then(({Image}) =>
+ Image.configure({inline: true}),
+ ),
+ import('@tinkoff/tui-editor').then(({TuiMarkdown}) =>
+ TuiMarkdown.configure({
+ html: true, // Allow HTML input/output
+ tightLists: true, // No
inside
in markdown output
+ tightListClass: 'tight', // Add class to allowing you to remove margins when tight
+ bulletListMarker: '-', //
- prefix in markdown output
+ linkify: true, // Create links from "https://..." text
+ breaks: true, // New lines (\n) in markdown input are converted to
+ transformPastedText: true, // Allow to paste markdown text in the editor
+ transformCopiedText: true, // Copied text is transformed to markdown
+ }),
+ ),
+ ],
+ },
+ TuiDestroyService,
+ ],
+})
+export default class ExampleComponent implements OnInit {
+ @ViewChild(TuiEditorComponent)
+ private readonly editorRef?: TuiEditorComponent;
+
+ private readonly destroy$ = inject(TuiDestroyService, {self: true});
+
+ protected markdown$ = new Subject();
+
+ protected readonly builtInTools = [TuiEditorTool.Undo];
+
+ protected control: FormControl = new FormControl(markdown);
+
+ public ngOnInit(): void {
+ this.markdown$
+ .pipe(debounceTime(500), takeUntil(this.destroy$))
+ .subscribe(value => this.editor?.commands.setContent(value));
+ }
+
+ protected get editor(): Editor | null {
+ return this.editorRef?.editorService.getOriginTiptapEditor() ?? null;
+ }
+
+ protected get markdown(): string {
+ return this.editor?.storage?.markdown?.getMarkdown() ?? '';
+ }
+}
diff --git a/projects/demo/src/app/pages/processing/markdown-extension/index.html b/projects/demo/src/app/pages/processing/markdown-extension/index.html
new file mode 100644
index 000000000..4d983d19f
--- /dev/null
+++ b/projects/demo/src/app/pages/processing/markdown-extension/index.html
@@ -0,0 +1,11 @@
+
+
+
diff --git a/projects/demo/src/app/pages/processing/markdown-extension/index.ts b/projects/demo/src/app/pages/processing/markdown-extension/index.ts
new file mode 100644
index 000000000..e768aa776
--- /dev/null
+++ b/projects/demo/src/app/pages/processing/markdown-extension/index.ts
@@ -0,0 +1,17 @@
+import {ChangeDetectionStrategy, Component} from '@angular/core';
+import type {TuiDocExample} from '@taiga-ui/addon-doc';
+import {TuiAddonDocModule} from '@taiga-ui/addon-doc';
+
+@Component({
+ standalone: true,
+ imports: [TuiAddonDocModule],
+ templateUrl: './index.html',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export default class ExampleComponent {
+ protected component1 = import('./examples/1');
+ protected readonly example1: TuiDocExample = {
+ TypeScript: import('./examples/1/index.ts?raw'),
+ HTML: import('./examples/1/index.html?raw'),
+ };
+}
diff --git a/projects/demo/src/app/pages/processing/markdown/index.html b/projects/demo/src/app/pages/processing/markdown/index.html
index 7e68fee2d..bfbef9862 100644
--- a/projects/demo/src/app/pages/processing/markdown/index.html
+++ b/projects/demo/src/app/pages/processing/markdown/index.html
@@ -4,7 +4,7 @@
>
this.patchContentEditableElement());
- protected readonly editorService = inject(TuiTiptapEditorService);
-
public get editor(): AbstractTuiEditor | null {
return this.editorService.getOriginTiptapEditor() ? this.editorService : null;
}
diff --git a/projects/tui-editor/src/extensions/markdown/clipboard/index.ts b/projects/tui-editor/src/extensions/markdown/clipboard/index.ts
new file mode 100644
index 000000000..1e2ab257d
--- /dev/null
+++ b/projects/tui-editor/src/extensions/markdown/clipboard/index.ts
@@ -0,0 +1,51 @@
+import {Extension} from '@tiptap/core';
+import {DOMParser} from '@tiptap/pm/model';
+import {Plugin, PluginKey} from '@tiptap/pm/state';
+import type {Slice} from 'prosemirror-model';
+
+import {tuiElementFromString} from '../util/dom';
+
+export const TuiMarkdownClipboard = Extension.create({
+ name: 'markdownClipboard',
+ addOptions() {
+ return {
+ transformPastedText: false,
+ transformCopiedText: false,
+ };
+ },
+ addProseMirrorPlugins() {
+ return [
+ new Plugin({
+ key: new PluginKey('markdownClipboard'),
+ props: {
+ clipboardTextParser: (text, context, plainText): Slice => {
+ if (plainText || !this.options.transformPastedText) {
+ return null as any; // pasting with shift key prevents formatting
+ }
+
+ const parsed = this.editor.storage.markdown.parser.parse(text, {
+ inline: true,
+ });
+
+ return DOMParser.fromSchema(this.editor.schema).parseSlice(
+ tuiElementFromString(parsed),
+ {
+ preserveWhitespace: true,
+ context,
+ },
+ );
+ },
+ clipboardTextSerializer: slice => {
+ if (!this.options.transformCopiedText) {
+ return null;
+ }
+
+ return this.editor.storage.markdown.serializer.serialize(
+ slice.content,
+ );
+ },
+ },
+ }),
+ ];
+ },
+});
diff --git a/projects/tui-editor/src/extensions/markdown/extensions/all.ts b/projects/tui-editor/src/extensions/markdown/extensions/all.ts
new file mode 100644
index 000000000..46d9e3e4f
--- /dev/null
+++ b/projects/tui-editor/src/extensions/markdown/extensions/all.ts
@@ -0,0 +1,45 @@
+import Bold from './marks/bold';
+import Code from './marks/code';
+import HTMLMark from './marks/html';
+import Italic from './marks/italic';
+import Link from './marks/link';
+import Strike from './marks/strike';
+import Blockquote from './nodes/blockquote';
+import BulletList from './nodes/bullet-list';
+import CodeBlock from './nodes/code-block';
+import HardBreak from './nodes/hard-break';
+import Heading from './nodes/heading';
+import HorizontalRule from './nodes/horizontal-rule';
+import HTMLNode from './nodes/html';
+import Image from './nodes/image';
+import ListItem from './nodes/list-item';
+import OrderedList from './nodes/ordered-list';
+import Paragraph from './nodes/paragraph';
+import Table from './nodes/table';
+import TaskItem from './nodes/task-item';
+import TaskList from './nodes/task-list';
+import Text from './nodes/text';
+
+export default [
+ Blockquote,
+ BulletList,
+ CodeBlock,
+ HardBreak,
+ Heading,
+ HorizontalRule,
+ HTMLNode,
+ Image,
+ ListItem,
+ OrderedList,
+ Paragraph,
+ Table,
+ TaskItem,
+ TaskList,
+ Text,
+ Bold,
+ Code,
+ HTMLMark,
+ Italic,
+ Link,
+ Strike,
+];
diff --git a/projects/tui-editor/src/extensions/markdown/extensions/marks/bold.ts b/projects/tui-editor/src/extensions/markdown/extensions/marks/bold.ts
new file mode 100644
index 000000000..8b9fffb74
--- /dev/null
+++ b/projects/tui-editor/src/extensions/markdown/extensions/marks/bold.ts
@@ -0,0 +1,17 @@
+import {Mark} from '@tiptap/core';
+import {defaultMarkdownSerializer} from 'prosemirror-markdown';
+
+export default Mark.create({
+ name: 'bold',
+}).extend({
+ addStorage() {
+ return {
+ markdown: {
+ serialize: defaultMarkdownSerializer.marks.strong,
+ parse: {
+ // handled by markdown-it
+ },
+ },
+ };
+ },
+});
diff --git a/projects/tui-editor/src/extensions/markdown/extensions/marks/code.ts b/projects/tui-editor/src/extensions/markdown/extensions/marks/code.ts
new file mode 100644
index 000000000..26db2997c
--- /dev/null
+++ b/projects/tui-editor/src/extensions/markdown/extensions/marks/code.ts
@@ -0,0 +1,17 @@
+import {Mark} from '@tiptap/core';
+import {defaultMarkdownSerializer} from 'prosemirror-markdown';
+
+export default Mark.create({
+ name: 'code',
+}).extend({
+ addStorage() {
+ return {
+ markdown: {
+ serialize: defaultMarkdownSerializer.marks.code,
+ parse: {
+ // handled by markdown-it
+ },
+ },
+ };
+ },
+});
diff --git a/projects/tui-editor/src/extensions/markdown/extensions/marks/html.ts b/projects/tui-editor/src/extensions/markdown/extensions/marks/html.ts
new file mode 100644
index 000000000..5e1db606a
--- /dev/null
+++ b/projects/tui-editor/src/extensions/markdown/extensions/marks/html.ts
@@ -0,0 +1,52 @@
+import type {Editor} from '@tiptap/core';
+import {getHTMLFromFragment, Mark} from '@tiptap/core';
+import {Fragment} from '@tiptap/pm/model';
+import type {Mark as ProseMark} from 'prosemirror-model';
+
+function getMarkTags(mark: ProseMark): string[] | null {
+ const schema = mark.type.schema;
+ const node = schema.text(' ', [mark]);
+ const html = getHTMLFromFragment(Fragment.from(node), schema);
+ const match = html.match(/^(<.*?>) (<\/.*?>)$/);
+
+ return match ? [match[1], match[2]] : null;
+}
+
+export default Mark.create({
+ name: 'markdownHTMLMark',
+ addStorage() {
+ return {
+ markdown: {
+ serialize: {
+ open(_: any, mark: ProseMark) {
+ if (
+ !((this as any).editor as Editor)?.storage.markdown.options
+ .html
+ ) {
+ console.warn(
+ `Tiptap Markdown: "${mark.type.name}" mark is only available in html mode`,
+ );
+
+ return '';
+ }
+
+ return getMarkTags(mark)?.[0] ?? '';
+ },
+ close(_: any, mark: ProseMark): string {
+ if (
+ !((this as any).editor as Editor)?.storage.markdown.options
+ .html
+ ) {
+ return '';
+ }
+
+ return getMarkTags(mark)?.[1] ?? '';
+ },
+ },
+ parse: {
+ // handled by markdown-it
+ },
+ },
+ };
+ },
+});
diff --git a/projects/tui-editor/src/extensions/markdown/extensions/marks/italic.ts b/projects/tui-editor/src/extensions/markdown/extensions/marks/italic.ts
new file mode 100644
index 000000000..0613b3f64
--- /dev/null
+++ b/projects/tui-editor/src/extensions/markdown/extensions/marks/italic.ts
@@ -0,0 +1,17 @@
+import {Mark} from '@tiptap/core';
+import {defaultMarkdownSerializer} from 'prosemirror-markdown';
+
+export default Mark.create({
+ name: 'italic',
+}).extend({
+ addStorage() {
+ return {
+ markdown: {
+ serialize: defaultMarkdownSerializer.marks.em,
+ parse: {
+ // handled by markdown-it
+ },
+ },
+ };
+ },
+});
diff --git a/projects/tui-editor/src/extensions/markdown/extensions/marks/link.ts b/projects/tui-editor/src/extensions/markdown/extensions/marks/link.ts
new file mode 100644
index 000000000..0f49d6464
--- /dev/null
+++ b/projects/tui-editor/src/extensions/markdown/extensions/marks/link.ts
@@ -0,0 +1,17 @@
+import {Mark} from '@tiptap/core';
+import {defaultMarkdownSerializer} from 'prosemirror-markdown';
+
+export default Mark.create({
+ name: 'link',
+}).extend({
+ addStorage() {
+ return {
+ markdown: {
+ serialize: defaultMarkdownSerializer.marks.link,
+ parse: {
+ // handled by markdown-it
+ },
+ },
+ };
+ },
+});
diff --git a/projects/tui-editor/src/extensions/markdown/extensions/marks/strike.ts b/projects/tui-editor/src/extensions/markdown/extensions/marks/strike.ts
new file mode 100644
index 000000000..209818530
--- /dev/null
+++ b/projects/tui-editor/src/extensions/markdown/extensions/marks/strike.ts
@@ -0,0 +1,16 @@
+import {Mark} from '@tiptap/core';
+
+export default Mark.create({
+ name: 'strike',
+}).extend({
+ addStorage() {
+ return {
+ markdown: {
+ serialize: {open: '~~', close: '~~', expelEnclosingWhitespace: true},
+ parse: {
+ // handled by markdown-it
+ },
+ },
+ };
+ },
+});
diff --git a/projects/tui-editor/src/extensions/markdown/extensions/nodes/blockquote.ts b/projects/tui-editor/src/extensions/markdown/extensions/nodes/blockquote.ts
new file mode 100644
index 000000000..13c8bf749
--- /dev/null
+++ b/projects/tui-editor/src/extensions/markdown/extensions/nodes/blockquote.ts
@@ -0,0 +1,17 @@
+import {Node} from '@tiptap/core';
+import {defaultMarkdownSerializer} from 'prosemirror-markdown';
+
+export default Node.create({
+ name: 'blockquote',
+}).extend({
+ addStorage() {
+ return {
+ markdown: {
+ serialize: defaultMarkdownSerializer.nodes.blockquote,
+ parse: {
+ // handled by markdown-it
+ },
+ },
+ };
+ },
+});
diff --git a/projects/tui-editor/src/extensions/markdown/extensions/nodes/bullet-list.ts b/projects/tui-editor/src/extensions/markdown/extensions/nodes/bullet-list.ts
new file mode 100644
index 000000000..9902d8102
--- /dev/null
+++ b/projects/tui-editor/src/extensions/markdown/extensions/nodes/bullet-list.ts
@@ -0,0 +1,28 @@
+import type {Editor} from '@tiptap/core';
+import {Node} from '@tiptap/core';
+import type {Node as ProseNode} from 'prosemirror-model';
+
+export default Node.create({
+ name: 'bulletList',
+}).extend({
+ addStorage() {
+ return {
+ markdown: {
+ serialize(state: any, node: ProseNode) {
+ return state.renderList(
+ node,
+ ' ',
+ () =>
+ `${
+ ((this as any).editor as Editor)?.storage.markdown.options
+ .bulletListMarker || '-'
+ } `,
+ );
+ },
+ parse: {
+ // handled by markdown-it
+ },
+ },
+ };
+ },
+});
diff --git a/projects/tui-editor/src/extensions/markdown/extensions/nodes/code-block.ts b/projects/tui-editor/src/extensions/markdown/extensions/nodes/code-block.ts
new file mode 100644
index 000000000..28afc5ff7
--- /dev/null
+++ b/projects/tui-editor/src/extensions/markdown/extensions/nodes/code-block.ts
@@ -0,0 +1,35 @@
+import {Node} from '@tiptap/core';
+import type MarkdownIt from 'markdown-it';
+import type {Node as ProseNode} from 'prosemirror-model';
+
+export default Node.create({
+ name: 'codeBlock',
+}).extend({
+ addStorage() {
+ return {
+ markdown: {
+ serialize(state: any, node: ProseNode) {
+ state.write(`\`\`\`${node.attrs.language || ''}\n`);
+ state.text(node.textContent, false);
+ state.ensureNewLine();
+ state.write('```');
+ state.closeBlock(node);
+ },
+ parse: {
+ setup(markdown: MarkdownIt) {
+ markdown.set({
+ langPrefix:
+ (this as any).options?.languageClassPrefix ?? 'language-',
+ });
+ },
+ updateDOM(element: Element) {
+ element.innerHTML = element.innerHTML.replaceAll(
+ '\n',
+ '',
+ );
+ },
+ },
+ },
+ };
+ },
+});
diff --git a/projects/tui-editor/src/extensions/markdown/extensions/nodes/hard-break.ts b/projects/tui-editor/src/extensions/markdown/extensions/nodes/hard-break.ts
new file mode 100644
index 000000000..b697d2379
--- /dev/null
+++ b/projects/tui-editor/src/extensions/markdown/extensions/nodes/hard-break.ts
@@ -0,0 +1,36 @@
+import {Node} from '@tiptap/core';
+import type {Node as ProseNode} from 'prosemirror-model';
+
+import HTMLNode from './html';
+
+export default Node.create({
+ name: 'hardBreak',
+}).extend({
+ addStorage() {
+ return {
+ markdown: {
+ serialize(state: any, node: ProseNode, parent: ProseNode, index: number) {
+ for (let i = index + 1; i < parent.childCount; i++) {
+ if (parent.child(i).type !== node.type) {
+ state.write(
+ state.inTable
+ ? HTMLNode.storage.markdown.serialize.call(
+ this,
+ state,
+ node,
+ parent,
+ )
+ : '\\\n',
+ );
+
+ return;
+ }
+ }
+ },
+ parse: {
+ // handled by markdown-it
+ },
+ },
+ };
+ },
+});
diff --git a/projects/tui-editor/src/extensions/markdown/extensions/nodes/heading.ts b/projects/tui-editor/src/extensions/markdown/extensions/nodes/heading.ts
new file mode 100644
index 000000000..7222adefe
--- /dev/null
+++ b/projects/tui-editor/src/extensions/markdown/extensions/nodes/heading.ts
@@ -0,0 +1,17 @@
+import {Node} from '@tiptap/core';
+import {defaultMarkdownSerializer} from 'prosemirror-markdown';
+
+export default Node.create({
+ name: 'heading',
+}).extend({
+ addStorage() {
+ return {
+ markdown: {
+ serialize: defaultMarkdownSerializer.nodes.heading,
+ parse: {
+ // handled by markdown-it
+ },
+ },
+ };
+ },
+});
diff --git a/projects/tui-editor/src/extensions/markdown/extensions/nodes/horizontal-rule.ts b/projects/tui-editor/src/extensions/markdown/extensions/nodes/horizontal-rule.ts
new file mode 100644
index 000000000..3895175af
--- /dev/null
+++ b/projects/tui-editor/src/extensions/markdown/extensions/nodes/horizontal-rule.ts
@@ -0,0 +1,17 @@
+import {Node} from '@tiptap/core';
+import {defaultMarkdownSerializer} from 'prosemirror-markdown';
+
+export default Node.create({
+ name: 'horizontalRule',
+}).extend({
+ addStorage() {
+ return {
+ markdown: {
+ serialize: defaultMarkdownSerializer.nodes.horizontal_rule,
+ parse: {
+ // handled by markdown-it
+ },
+ },
+ };
+ },
+});
diff --git a/projects/tui-editor/src/extensions/markdown/extensions/nodes/html.ts b/projects/tui-editor/src/extensions/markdown/extensions/nodes/html.ts
new file mode 100644
index 000000000..a49c2c246
--- /dev/null
+++ b/projects/tui-editor/src/extensions/markdown/extensions/nodes/html.ts
@@ -0,0 +1,58 @@
+import type {Editor} from '@tiptap/core';
+import {getHTMLFromFragment, Node} from '@tiptap/core';
+import {Fragment} from '@tiptap/pm/model';
+import type {Node as ProseNode} from 'prosemirror-model';
+
+import {tuiElementFromString} from '../../util/dom';
+
+export default Node.create({
+ name: 'markdownHTMLNode',
+ addStorage() {
+ return {
+ markdown: {
+ serialize(state: any, node: ProseNode, parent: ProseNode) {
+ if (((this as any).editor as Editor).storage.markdown.options.html) {
+ state.write(serializeHTML(node, parent));
+ } else {
+ console.warn(
+ `Tiptap Markdown: "${node.type.name}" node is only available in html mode`,
+ );
+ state.write(`[${node.type.name}]`);
+ }
+
+ if (node.isBlock) {
+ state.closeBlock(node);
+ }
+ },
+ parse: {
+ // handled by markdown-it
+ },
+ },
+ };
+ },
+});
+
+function serializeHTML(node: ProseNode, parent: ProseNode): string {
+ const schema = node.type.schema;
+ const html = getHTMLFromFragment(Fragment.from(node), schema);
+
+ if (
+ node.isBlock &&
+ (parent instanceof Fragment || parent.type.name === schema.topNodeType.name)
+ ) {
+ return formatBlock(html);
+ }
+
+ return html;
+}
+
+function formatBlock(html: string): string {
+ const dom = tuiElementFromString(html);
+ const element = dom.firstElementChild;
+
+ if (element) {
+ element.innerHTML = element.innerHTML.trim() ? `\n${element.innerHTML}\n` : '\n';
+ }
+
+ return element?.outerHTML ?? '';
+}
diff --git a/projects/tui-editor/src/extensions/markdown/extensions/nodes/image.ts b/projects/tui-editor/src/extensions/markdown/extensions/nodes/image.ts
new file mode 100644
index 000000000..6b941a41e
--- /dev/null
+++ b/projects/tui-editor/src/extensions/markdown/extensions/nodes/image.ts
@@ -0,0 +1,17 @@
+import {Node} from '@tiptap/core';
+import {defaultMarkdownSerializer} from 'prosemirror-markdown';
+
+export default Node.create({
+ name: 'image',
+}).extend({
+ addStorage() {
+ return {
+ markdown: {
+ serialize: defaultMarkdownSerializer.nodes.image,
+ parse: {
+ // handled by markdown-it
+ },
+ },
+ };
+ },
+});
diff --git a/projects/tui-editor/src/extensions/markdown/extensions/nodes/list-item.ts b/projects/tui-editor/src/extensions/markdown/extensions/nodes/list-item.ts
new file mode 100644
index 000000000..e8ec3198f
--- /dev/null
+++ b/projects/tui-editor/src/extensions/markdown/extensions/nodes/list-item.ts
@@ -0,0 +1,17 @@
+import {Node} from '@tiptap/core';
+import {defaultMarkdownSerializer} from 'prosemirror-markdown';
+
+export default Node.create({
+ name: 'listItem',
+}).extend({
+ addStorage() {
+ return {
+ markdown: {
+ serialize: defaultMarkdownSerializer.nodes.list_item,
+ parse: {
+ // handled by markdown-it
+ },
+ },
+ };
+ },
+});
diff --git a/projects/tui-editor/src/extensions/markdown/extensions/nodes/ordered-list.ts b/projects/tui-editor/src/extensions/markdown/extensions/nodes/ordered-list.ts
new file mode 100644
index 000000000..20ce5f00c
--- /dev/null
+++ b/projects/tui-editor/src/extensions/markdown/extensions/nodes/ordered-list.ts
@@ -0,0 +1,45 @@
+import {Node} from '@tiptap/core';
+import type {Node as ProseNode} from 'prosemirror-model';
+
+function findIndexOfAdjacentNode(
+ node: ProseNode,
+ parent: ProseNode,
+ index: number,
+): number {
+ let i = 0;
+
+ for (; index - i > 0; i++) {
+ if (parent.child(index - i - 1).type.name !== node.type.name) {
+ break;
+ }
+ }
+
+ return i;
+}
+
+export default Node.create({
+ name: 'orderedList',
+}).extend({
+ addStorage() {
+ return {
+ markdown: {
+ serialize(state: any, node: ProseNode, parent: ProseNode, index: number) {
+ const start = node.attrs.start || 1;
+ const maxW = String(start + node.childCount - 1).length;
+ const space = state.repeat(' ', maxW + 2);
+ const adjacentIndex = findIndexOfAdjacentNode(node, parent, index);
+ const separator = adjacentIndex % 2 ? ') ' : '. ';
+
+ state.renderList(node, space, (i: number) => {
+ const nStr = String(start + i);
+
+ return state.repeat(' ', maxW - nStr.length) + nStr + separator;
+ });
+ },
+ parse: {
+ // handled by markdown-it
+ },
+ },
+ };
+ },
+});
diff --git a/projects/tui-editor/src/extensions/markdown/extensions/nodes/paragraph.ts b/projects/tui-editor/src/extensions/markdown/extensions/nodes/paragraph.ts
new file mode 100644
index 000000000..4d7f10bb6
--- /dev/null
+++ b/projects/tui-editor/src/extensions/markdown/extensions/nodes/paragraph.ts
@@ -0,0 +1,17 @@
+import {Node} from '@tiptap/core';
+import {defaultMarkdownSerializer} from 'prosemirror-markdown';
+
+export default Node.create({
+ name: 'paragraph',
+}).extend({
+ addStorage() {
+ return {
+ markdown: {
+ serialize: defaultMarkdownSerializer.nodes.paragraph,
+ parse: {
+ // handled by markdown-it
+ },
+ },
+ };
+ },
+});
diff --git a/projects/tui-editor/src/extensions/markdown/extensions/nodes/table.ts b/projects/tui-editor/src/extensions/markdown/extensions/nodes/table.ts
new file mode 100644
index 000000000..38c2743dd
--- /dev/null
+++ b/projects/tui-editor/src/extensions/markdown/extensions/nodes/table.ts
@@ -0,0 +1,90 @@
+import {Node} from '@tiptap/core';
+import type {Node as ProseNode} from 'prosemirror-model';
+
+import {tuiChildNodes} from '../../util/prosemirror';
+import HTMLNode from './html';
+
+export default Node.create({
+ name: 'table',
+}).extend({
+ addStorage() {
+ return {
+ markdown: {
+ serialize(state: any, node: ProseNode, parent: ProseNode) {
+ if (!isMarkdownSerializable(node)) {
+ HTMLNode.storage.markdown.serialize.call(
+ this,
+ state,
+ node,
+ parent,
+ );
+
+ return;
+ }
+
+ state.inTable = true;
+
+ node.forEach((row: ProseNode, _p: number, i: number) => {
+ state.write('| ');
+
+ row.forEach((col: ProseNode, __p: number, j: number) => {
+ if (j) {
+ state.write(' | ');
+ }
+
+ const cellContent = col.firstChild;
+
+ if (cellContent?.textContent.trim()) {
+ state.renderInline(cellContent);
+ }
+ });
+
+ state.write(' |');
+ state.ensureNewLine();
+
+ if (!i) {
+ const delimiterRow = Array.from({length: row.childCount})
+ .map(() => '---')
+ .join(' | ');
+
+ state.write(`| ${delimiterRow} |`);
+ state.ensureNewLine();
+ }
+ });
+
+ state.closeBlock(node);
+ state.inTable = false;
+ },
+ parse: {
+ // handled by markdown-it
+ },
+ },
+ };
+ },
+});
+
+function hasSpan(node: ProseNode): boolean {
+ return node.attrs.colspan > 1 || node.attrs.rowspan > 1;
+}
+
+function isMarkdownSerializable(node: ProseNode): boolean {
+ const rows = tuiChildNodes(node);
+ const firstRow = rows[0];
+ const bodyRows = rows.slice(1);
+
+ if (
+ tuiChildNodes(firstRow).some(
+ cell =>
+ cell.type.name !== 'tableHeader' || hasSpan(cell) || cell.childCount > 1,
+ )
+ ) {
+ return false;
+ }
+
+ return !bodyRows.some(row =>
+ tuiChildNodes(row).some(
+ cell =>
+ cell.type.name === 'tableHeader' || hasSpan(cell) || cell.childCount > 1,
+ ),
+ );
+}
diff --git a/projects/tui-editor/src/extensions/markdown/extensions/nodes/task-item.ts b/projects/tui-editor/src/extensions/markdown/extensions/nodes/task-item.ts
new file mode 100644
index 000000000..d1f23c706
--- /dev/null
+++ b/projects/tui-editor/src/extensions/markdown/extensions/nodes/task-item.ts
@@ -0,0 +1,38 @@
+import {Node} from '@tiptap/core';
+import type {Node as ProseNode} from 'prosemirror-model';
+
+export default Node.create({
+ name: 'taskItem',
+}).extend({
+ addStorage() {
+ return {
+ markdown: {
+ serialize(state: any, node: ProseNode) {
+ const check = node.attrs.checked ? '[x]' : '[ ]';
+
+ state.write(`${check} `);
+ state.renderContent(node);
+ },
+ parse: {
+ updateDOM(element: Element) {
+ Array.from(element.querySelectorAll('.task-list-item')).forEach(
+ item => {
+ const input = item.querySelector('input');
+
+ item.setAttribute('data-type', 'taskItem');
+
+ if (input) {
+ item.setAttribute(
+ 'data-checked',
+ input.checked.toString(),
+ );
+ input.remove();
+ }
+ },
+ );
+ },
+ },
+ },
+ };
+ },
+});
diff --git a/projects/tui-editor/src/extensions/markdown/extensions/nodes/task-list.ts b/projects/tui-editor/src/extensions/markdown/extensions/nodes/task-list.ts
new file mode 100644
index 000000000..127b3c070
--- /dev/null
+++ b/projects/tui-editor/src/extensions/markdown/extensions/nodes/task-list.ts
@@ -0,0 +1,29 @@
+import {Node} from '@tiptap/core';
+import type MarkdownIt from 'markdown-it';
+
+import {tuiMarkdownItTaskList} from '../../util/markdown-it-task-lists';
+import BulletList from './bullet-list';
+
+export default Node.create({
+ name: 'taskList',
+}).extend({
+ addStorage() {
+ return {
+ markdown: {
+ serialize: BulletList.storage.markdown.serialize,
+ parse: {
+ setup(markdown: MarkdownIt) {
+ markdown.use(tuiMarkdownItTaskList);
+ },
+ updateDOM(element: Element) {
+ Array.from(
+ element.querySelectorAll('.contains-task-list'),
+ ).forEach(list => {
+ list.setAttribute('data-type', 'taskList');
+ });
+ },
+ },
+ },
+ };
+ },
+});
diff --git a/projects/tui-editor/src/extensions/markdown/extensions/nodes/text.ts b/projects/tui-editor/src/extensions/markdown/extensions/nodes/text.ts
new file mode 100644
index 000000000..eee02fd27
--- /dev/null
+++ b/projects/tui-editor/src/extensions/markdown/extensions/nodes/text.ts
@@ -0,0 +1,21 @@
+import {Node} from '@tiptap/core';
+import type {Node as ProseNode} from 'prosemirror-model';
+
+import {tuiEscapeHTML} from '../../util/dom';
+
+export default Node.create({
+ name: 'text',
+}).extend({
+ addStorage() {
+ return {
+ markdown: {
+ serialize(state: any, node: ProseNode) {
+ state.text(tuiEscapeHTML(node.text));
+ },
+ parse: {
+ // handled by markdown-it
+ },
+ },
+ };
+ },
+});
diff --git a/projects/tui-editor/src/extensions/markdown/index.ts b/projects/tui-editor/src/extensions/markdown/index.ts
new file mode 100644
index 000000000..fa3d61f5f
--- /dev/null
+++ b/projects/tui-editor/src/extensions/markdown/index.ts
@@ -0,0 +1,3 @@
+export * from './clipboard';
+export * from './markdown.extension';
+export * from './tight-lists';
diff --git a/projects/tui-editor/src/extensions/markdown/markdown.extension.ts b/projects/tui-editor/src/extensions/markdown/markdown.extension.ts
new file mode 100644
index 000000000..791718f84
--- /dev/null
+++ b/projects/tui-editor/src/extensions/markdown/markdown.extension.ts
@@ -0,0 +1,75 @@
+import {Extension, extensions} from '@tiptap/core';
+
+import {TuiMarkdownClipboard} from './clipboard';
+import {TuiEditorMarkdownParser} from './parse/markdown-parser';
+import {TuiMarkdownSerializer} from './serialize/markdown-serializer';
+import {TuiMarkdownTightLists} from './tight-lists';
+
+export const TuiMarkdown = Extension.create({
+ name: 'markdown',
+ priority: 50,
+ addOptions() {
+ return {
+ html: true,
+ tightLists: true,
+ tightListClass: 'tight',
+ bulletListMarker: '-',
+ linkify: false,
+ breaks: false,
+ transformPastedText: false,
+ transformCopiedText: false,
+ };
+ },
+ addCommands() {
+ const commands = (extensions?.Commands?.config as any)?.addCommands?.();
+
+ return {
+ setContent: (content, emitUpdate, parseOptions) => props =>
+ commands?.setContent?.(
+ props.editor.storage.markdown.parser.parse(content),
+ emitUpdate,
+ parseOptions,
+ )(props),
+ insertContentAt: (range, content, options) => props =>
+ commands?.insertContentAt?.(
+ range,
+ props.editor.storage.markdown.parser.parse(content, {inline: true}),
+ options,
+ )(props),
+ };
+ },
+ onBeforeCreate() {
+ this.editor.storage.markdown = {
+ options: {...this.options},
+ parser: new TuiEditorMarkdownParser(this.editor, this.options),
+ serializer: new TuiMarkdownSerializer(this.editor),
+ getMarkdown: () =>
+ this.editor.storage.markdown.serializer.serialize(this.editor.state.doc),
+ };
+ (this.editor.options as any).initialContent = this.editor.options.content;
+ this.editor.options.content = this.editor.storage.markdown.parser.parse(
+ this.editor.options.content,
+ );
+ },
+ onCreate() {
+ this.editor.options.content = (this.editor.options as any).initialContent;
+ delete (this.editor.options as any).initialContent;
+ },
+ addStorage() {
+ return {
+ /// storage will be defined in onBeforeCreate() to prevent initial object overriding
+ };
+ },
+ addExtensions() {
+ return [
+ TuiMarkdownTightLists.configure({
+ tight: this.options.tightLists,
+ tightClass: this.options.tightListClass,
+ }),
+ TuiMarkdownClipboard.configure({
+ transformPastedText: this.options.transformPastedText,
+ transformCopiedText: this.options.transformCopiedText,
+ }),
+ ];
+ },
+});
diff --git a/projects/tui-editor/src/extensions/markdown/ng-package.json b/projects/tui-editor/src/extensions/markdown/ng-package.json
new file mode 100644
index 000000000..bebf62dcb
--- /dev/null
+++ b/projects/tui-editor/src/extensions/markdown/ng-package.json
@@ -0,0 +1,5 @@
+{
+ "lib": {
+ "entryFile": "index.ts"
+ }
+}
diff --git a/projects/tui-editor/src/extensions/markdown/parse/markdown-parser.ts b/projects/tui-editor/src/extensions/markdown/parse/markdown-parser.ts
new file mode 100644
index 000000000..386b8ec02
--- /dev/null
+++ b/projects/tui-editor/src/extensions/markdown/parse/markdown-parser.ts
@@ -0,0 +1,141 @@
+import type {Editor} from '@tiptap/core';
+import MarkdownIt from 'markdown-it';
+import type {RenderRule} from 'markdown-it/lib/renderer';
+
+import {tuiElementFromString, tuiExtractElement, tuiUnwrapElement} from '../util/dom';
+import {tuiGetMarkdownSpec} from '../util/extensions';
+
+export class TuiEditorMarkdownParser {
+ protected readonly md: MarkdownIt;
+
+ constructor(
+ protected readonly editor: Editor,
+ {html, linkify, breaks}: MarkdownIt.Options,
+ ) {
+ this.md = this.withPatchedRenderer(
+ MarkdownIt({
+ html,
+ linkify,
+ breaks,
+ }),
+ );
+ }
+
+ protected parse(content: unknown, {inline}: Record = {}): string {
+ if (typeof content === 'string') {
+ this.editor.extensionManager.extensions.forEach(extension =>
+ tuiGetMarkdownSpec(extension)?.parse?.setup?.call(
+ {editor: this.editor, options: extension.options},
+ this.md,
+ ),
+ );
+
+ const renderedHTML = this.md.render(content);
+ const element = tuiElementFromString(renderedHTML);
+
+ this.editor.extensionManager.extensions.forEach(extension =>
+ tuiGetMarkdownSpec(extension)?.parse?.updateDOM?.call(
+ {editor: this.editor, options: extension.options},
+ element,
+ ),
+ );
+
+ this.normalizeDOM(element, {inline, content});
+
+ return element.innerHTML;
+ }
+
+ return content as string;
+ }
+
+ protected normalizeDOM(
+ node: Element,
+ {inline, content}: Record,
+ ): Element {
+ this.normalizeBlocks(node);
+
+ // remove all \n appended by markdown-it
+ node.querySelectorAll('*').forEach(el => {
+ if (el.nextSibling?.nodeType === Node.TEXT_NODE && !el.closest('pre')) {
+ el.nextSibling.textContent =
+ el.nextSibling.textContent?.replace(/^\n/, '') ?? '';
+ }
+ });
+
+ if (inline) {
+ this.normalizeInline(node, content);
+ }
+
+ return node;
+ }
+
+ protected normalizeBlocks(node: Element): void {
+ const blocks = Object.values(this.editor.schema.nodes).filter(
+ node => node.isBlock,
+ );
+
+ const selector = blocks
+ .map(block => block.spec.parseDOM?.map(spec => spec.tag))
+ .flat()
+ .filter(Boolean)
+ .join(',');
+
+ if (!selector) {
+ return;
+ }
+
+ Array.from(node.querySelectorAll(selector)).forEach(el => {
+ if (el.parentElement?.matches('p')) {
+ tuiExtractElement(el);
+ }
+ });
+ }
+
+ protected normalizeInline(node: Element, content: string): void {
+ if (node.firstElementChild?.matches('p')) {
+ const firstParagraph = node.firstElementChild;
+ const {nextElementSibling} = firstParagraph;
+ const startSpaces = content.match(/^\s+/)?.[0] ?? '';
+ const endSpaces = !nextElementSibling ? content.match(/\s+$/)?.[0] ?? '' : '';
+
+ if (content.match(/^\n\n/)) {
+ firstParagraph.innerHTML = `${firstParagraph.innerHTML}${endSpaces}`;
+
+ return;
+ }
+
+ tuiUnwrapElement(firstParagraph);
+
+ node.innerHTML = `${startSpaces}${node.innerHTML}${endSpaces}`;
+ }
+ }
+
+ protected withPatchedRenderer(md: MarkdownIt): MarkdownIt {
+ const withoutNewLine =
+ (renderer: RenderRule | undefined) =>
+ (...args: any[]): string => {
+ // @ts-ignore
+ const rendered = renderer?.(...args);
+
+ if (rendered === '\n') {
+ return rendered;
+ }
+
+ if (rendered?.endsWith('\n')) {
+ return rendered.slice(0, -1);
+ }
+
+ return rendered ?? '';
+ };
+
+ md.renderer.rules.hardbreak = withoutNewLine(md.renderer.rules.hardbreak);
+ md.renderer.rules.softbreak = withoutNewLine(md.renderer.rules.softbreak);
+ md.renderer.rules.fence = withoutNewLine(md.renderer.rules.fence);
+ md.renderer.rules.code_block = withoutNewLine(md.renderer.rules.code_block);
+ md.renderer.renderToken = withoutNewLine(
+ md.renderer.renderToken.bind(md.renderer),
+ );
+
+ return md;
+ }
+}
diff --git a/projects/tui-editor/src/extensions/markdown/serialize/markdown-serializer.ts b/projects/tui-editor/src/extensions/markdown/serialize/markdown-serializer.ts
new file mode 100644
index 000000000..9767578c1
--- /dev/null
+++ b/projects/tui-editor/src/extensions/markdown/serialize/markdown-serializer.ts
@@ -0,0 +1,101 @@
+import type {Editor, Mark as Mark2, Node as Node2} from '@tiptap/core';
+import type {Mark, Node} from 'prosemirror-model';
+
+import HTMLMark from '../extensions/marks/html';
+import HardBreak from '../extensions/nodes/hard-break';
+import HTMLNode from '../extensions/nodes/html';
+import {tuiGetMarkdownSpec} from '../util/extensions';
+import {TuiMarkdownSerializerState} from './state';
+
+export class TuiMarkdownSerializer {
+ constructor(protected readonly editor: Editor) {}
+
+ public get nodes(): readonly Node[] {
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
+ return {
+ ...Object.fromEntries(
+ Object.keys(this.editor.schema.nodes).map(name => [
+ name,
+ this.serializeNode(HTMLNode),
+ ]),
+ ),
+ ...Object.fromEntries(
+ this.editor.extensionManager.extensions
+ .filter(
+ extension =>
+ extension.type === 'node' &&
+ this.serializeNode(extension as any),
+ )
+ .map(extension => [
+ extension.name,
+ this.serializeNode(extension as any),
+ ]) ?? [],
+ ),
+ } as Node[];
+ }
+
+ public get marks(): readonly Mark[] {
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
+ return {
+ ...Object.fromEntries(
+ Object.keys(this.editor.schema.marks).map(name => [
+ name,
+ this.serializeMark(HTMLMark),
+ ]),
+ ),
+ ...Object.fromEntries(
+ this.editor.extensionManager.extensions
+ .filter(
+ extension =>
+ extension.type === 'mark' &&
+ this.serializeMark(extension as any),
+ )
+ .map(extension => [
+ extension.name,
+ this.serializeMark(extension as any),
+ ]) ?? [],
+ ),
+ } as Mark[];
+ }
+
+ public serialize(content: Node): any {
+ const state = new TuiMarkdownSerializerState(this.nodes, this.marks, {
+ hardBreakNodeName: HardBreak.name,
+ });
+
+ state.renderContent(content);
+
+ return state.out;
+ }
+
+ public serializeNode(node: Node2): any {
+ return tuiGetMarkdownSpec(node)?.serialize?.bind({
+ editor: this.editor,
+ options: node.options,
+ });
+ }
+
+ public serializeMark(mark: Mark2): any {
+ const serialize = tuiGetMarkdownSpec(mark)?.serialize;
+
+ return serialize
+ ? {
+ ...serialize,
+ open:
+ typeof serialize.open === 'function'
+ ? serialize.open.bind({
+ editor: this.editor,
+ options: mark.options,
+ })
+ : serialize.open,
+ close:
+ typeof serialize.close === 'function'
+ ? serialize.close.bind({
+ editor: this.editor,
+ options: mark.options,
+ })
+ : serialize.close,
+ }
+ : null;
+ }
+}
diff --git a/projects/tui-editor/src/extensions/markdown/serialize/state.ts b/projects/tui-editor/src/extensions/markdown/serialize/state.ts
new file mode 100644
index 000000000..7b3dfd39b
--- /dev/null
+++ b/projects/tui-editor/src/extensions/markdown/serialize/state.ts
@@ -0,0 +1,71 @@
+import {MarkdownSerializerState as BaseMarkdownSerializerState} from 'prosemirror-markdown';
+import type {Mark, Node} from 'prosemirror-model';
+
+import {tuiTrimInline} from '../util/markdown';
+
+export class TuiMarkdownSerializerState extends BaseMarkdownSerializerState {
+ public inTable = false;
+ public inlines: any[];
+ public out: any;
+ public marks: any;
+
+ constructor(
+ nodes: readonly Node[],
+ marks: readonly Mark[],
+ options: BaseMarkdownSerializerState['options'],
+ ) {
+ // @ts-ignore
+ super(nodes, marks, options ?? {});
+
+ this.inlines = [];
+ }
+
+ public override render(node: Node, parent: Node, index: number): void {
+ super.render(node, parent, index);
+ const top = this.inlines[this.inlines.length - 1];
+
+ if (top?.start && top?.end) {
+ const {delimiter, start, end} = this.normalizeInline(top);
+
+ this.out = tuiTrimInline(this.out, delimiter, start, end);
+ this.inlines.pop();
+ }
+ }
+
+ public override markString(
+ mark: Mark,
+ open: boolean,
+ parent: Node,
+ index: number,
+ ): string {
+ const info = this.marks[mark.type.name];
+
+ if (info.expelEnclosingWhitespace) {
+ if (open) {
+ this.inlines.push({
+ start: this.out.length,
+ delimiter: info.open,
+ });
+ } else {
+ const top = this.inlines.pop();
+
+ this.inlines.push({
+ ...top,
+ end: this.out.length,
+ });
+ }
+ }
+
+ return super.markString(mark, open, parent, index);
+ }
+
+ protected normalizeInline(inline: any): any {
+ let {start} = inline;
+
+ while (this.out.charAt(start).match(/\s/)) {
+ start++;
+ }
+
+ return {...inline, start};
+ }
+}
diff --git a/projects/tui-editor/src/extensions/markdown/tight-lists/index.ts b/projects/tui-editor/src/extensions/markdown/tight-lists/index.ts
new file mode 100644
index 000000000..09d6d4d6e
--- /dev/null
+++ b/projects/tui-editor/src/extensions/markdown/tight-lists/index.ts
@@ -0,0 +1,47 @@
+import {Extension} from '@tiptap/core';
+
+export const TuiMarkdownTightLists = Extension.create({
+ name: 'markdownTightLists',
+ addOptions: () => ({
+ tight: true,
+ tightClass: 'tight',
+ listTypes: ['bulletList', 'orderedList'],
+ }),
+ addGlobalAttributes() {
+ return [
+ {
+ types: this.options.listTypes,
+ attributes: {
+ tight: {
+ default: this.options.tight,
+ parseHTML: element =>
+ element.getAttribute('data-tight') === 'true' ||
+ !element.querySelector('p'),
+ renderHTML: attributes => ({
+ class: attributes.tight ? this.options.tightClass : null,
+ 'data-tight': attributes.tight ? 'true' : null,
+ }),
+ },
+ },
+ },
+ ];
+ },
+ addCommands(): any {
+ return {
+ toggleTight:
+ (tight = null) =>
+ ({editor, commands}: any) =>
+ this.options.listTypes.some(name => {
+ if (!editor.isActive(name)) {
+ return false;
+ }
+
+ const attrs = editor.getAttributes(name);
+
+ return commands.updateAttributes(name, {
+ tight: tight ?? !attrs?.tight,
+ });
+ }),
+ };
+ },
+});
diff --git a/projects/tui-editor/src/extensions/markdown/util/dom.ts b/projects/tui-editor/src/extensions/markdown/util/dom.ts
new file mode 100644
index 000000000..c8130e7f1
--- /dev/null
+++ b/projects/tui-editor/src/extensions/markdown/util/dom.ts
@@ -0,0 +1,38 @@
+export function tuiElementFromString(value: any): HTMLElement {
+ return new window.DOMParser().parseFromString(`${value}`, 'text/html')
+ .body;
+}
+
+export function tuiExtractElement(node: Node): void {
+ const parent = node.parentElement;
+
+ const prepend = parent?.cloneNode();
+
+ while (parent?.firstChild && parent.firstChild !== node) {
+ prepend?.appendChild(parent.firstChild);
+ }
+
+ if ((prepend?.childNodes?.length ?? 0) > 0 && prepend) {
+ parent?.parentElement?.insertBefore(prepend, parent);
+ }
+
+ parent?.parentElement?.insertBefore(node, parent);
+
+ if (parent?.childNodes.length === 0) {
+ parent.remove();
+ }
+}
+
+export function tuiUnwrapElement(node: Node): void {
+ const parent = node.parentNode;
+
+ while (node?.firstChild) {
+ parent?.insertBefore(node.firstChild, node);
+ }
+
+ parent?.removeChild(node);
+}
+
+export function tuiEscapeHTML(value?: string): string {
+ return value?.replace(/', '>') ?? '';
+}
diff --git a/projects/tui-editor/src/extensions/markdown/util/extensions.ts b/projects/tui-editor/src/extensions/markdown/util/extensions.ts
new file mode 100644
index 000000000..c30ddc0e0
--- /dev/null
+++ b/projects/tui-editor/src/extensions/markdown/util/extensions.ts
@@ -0,0 +1,16 @@
+import markdownExtensions from '../extensions/all';
+
+export function tuiGetMarkdownSpec(extension: any): any {
+ const markdownSpec = extension.storage?.markdown;
+ const defaultMarkdownSpec = markdownExtensions.find(e => e.name === extension.name)
+ ?.storage.markdown;
+
+ if (markdownSpec || defaultMarkdownSpec) {
+ return {
+ ...defaultMarkdownSpec,
+ ...markdownSpec,
+ };
+ }
+
+ return null;
+}
diff --git a/projects/tui-editor/src/extensions/markdown/util/markdown-it-task-lists.ts b/projects/tui-editor/src/extensions/markdown/util/markdown-it-task-lists.ts
new file mode 100644
index 000000000..2f7502303
--- /dev/null
+++ b/projects/tui-editor/src/extensions/markdown/util/markdown-it-task-lists.ts
@@ -0,0 +1,159 @@
+import type Token from 'markdown-it/lib/token';
+
+let disableCheckboxes = true;
+let useLabelWrapper = false;
+let useLabelAfter = false;
+
+export function tuiMarkdownItTaskList(md: any, options: any): void {
+ if (options) {
+ disableCheckboxes = !options.enabled;
+ useLabelWrapper = !!options.label;
+ useLabelAfter = !!options.labelAfter;
+ }
+
+ md.core.ruler.after('inline', 'github-task-lists', (state: any) => {
+ const tokens = state.tokens;
+
+ for (let i = 2; i < tokens.length; i++) {
+ if (isTodoItem(tokens, i)) {
+ todoify(tokens[i], state.Token);
+ attrSet(
+ tokens[i - 2],
+ 'class',
+ `task-list-item${!disableCheckboxes ? ' enabled' : ''}`,
+ );
+ attrSet(
+ tokens[parentToken(tokens, i - 2)],
+ 'class',
+ 'contains-task-list',
+ );
+ }
+ }
+ });
+}
+
+function attrSet(token: Token, name: string, value: string): void {
+ const index = token.attrIndex(name);
+ const attr: [string, string] = [name, value];
+
+ if (index < 0) {
+ token.attrPush(attr);
+ } else if (token.attrs) {
+ token.attrs[index] = attr;
+ }
+}
+
+function parentToken(tokens: Token[], index: number): number {
+ const targetLevel = tokens[index].level - 1;
+
+ for (let i = index - 1; i >= 0; i--) {
+ if (tokens[i].level === targetLevel) {
+ return i;
+ }
+ }
+
+ return -1;
+}
+
+function isTodoItem(tokens: Token[], index: number): boolean {
+ return (
+ isInline(tokens[index]) &&
+ isParagraph(tokens[index - 1]) &&
+ isListItem(tokens[index - 2]) &&
+ startsWithTodoMarkdown(tokens[index])
+ );
+}
+
+function todoify(token: Token, TokenConstructor: any): void {
+ token.children?.unshift(makeCheckbox(token, TokenConstructor));
+
+ if (token.children) {
+ token.children[1].content = token.children[1].content.slice(3);
+ }
+
+ if (token.content) {
+ token.content = token.content.slice(3);
+ }
+
+ if (useLabelWrapper) {
+ if (useLabelAfter) {
+ token.children?.pop();
+
+ // Use large random number as id property of the checkbox.
+ const id = `task-item-${Math.ceil(Math.random() * (10000 * 1000) - 1000)}`;
+
+ if (token.children) {
+ token.children[0].content = `${token.children[0].content.slice(
+ 0,
+ -1,
+ )} id="${id}">`;
+ }
+
+ token.children?.push(afterLabel(token.content, id, TokenConstructor));
+ } else {
+ token.children?.unshift(beginLabel(TokenConstructor));
+ token.children?.push(endLabel(TokenConstructor));
+ }
+ }
+}
+
+function makeCheckbox(token: Token, TokenConstructor: any): any {
+ const checkbox = new TokenConstructor('html_inline', '', 0);
+ const disabledAttr = disableCheckboxes ? ' disabled="" ' : '';
+
+ if (token?.content.startsWith('[ ] ')) {
+ checkbox.content = ``;
+ } else if (token?.content.startsWith('[x] ') || token?.content.startsWith('[X] ')) {
+ checkbox.content = ``;
+ }
+
+ return checkbox;
+}
+
+// these next two functions are kind of hacky; probably should really be a
+// true block-level token with .tag=='label'
+function beginLabel(TokenConstructor: any): Token {
+ const token = new TokenConstructor('html_inline', '', 0);
+
+ token.content = '';
+
+ return token;
+}
+
+function afterLabel(content: string, id: string, TokenConstructor: any): Token {
+ const token = new TokenConstructor('html_inline', '', 0);
+
+ token.content = ``;
+ token.attrs = [{for: id}];
+
+ return token;
+}
+
+function isInline(token: Token): boolean {
+ return token.type === 'inline';
+}
+
+function isParagraph(token: Token): boolean {
+ return token.type === 'paragraph_open';
+}
+
+function isListItem(token: Token): boolean {
+ return token.type === 'list_item_open';
+}
+
+function startsWithTodoMarkdown(token: Token): boolean {
+ // leading whitespace in a list item is already trimmed off by markdown-it
+ return (
+ token.content.startsWith('[ ] ') ||
+ token.content.startsWith('[x] ') ||
+ token.content.startsWith('[X] ')
+ );
+}
diff --git a/projects/tui-editor/src/extensions/markdown/util/markdown.ts b/projects/tui-editor/src/extensions/markdown/util/markdown.ts
new file mode 100644
index 000000000..aa6b6dffe
--- /dev/null
+++ b/projects/tui-editor/src/extensions/markdown/util/markdown.ts
@@ -0,0 +1,86 @@
+import markdownIt from 'markdown-it';
+import type StateInline from 'markdown-it/lib/rules_inline/state_inline';
+
+const md = markdownIt();
+
+function scanDelims(text: string, pos: number): StateInline.Scanned {
+ // @ts-ignore
+ md.inline.State.prototype.scanDelims.call({src: text, posMax: text.length});
+ // @ts-ignore
+ const state = new md.inline.State(text, null, null, []);
+
+ return state.scanDelims(pos, true);
+}
+
+function trimStart(text: string, delim: string, from: number, to: number): any {
+ let pos = from;
+ let res = text;
+
+ while (pos < to) {
+ if (scanDelims(res, pos).can_open) {
+ break;
+ }
+
+ res = tuiShiftDelim(res, delim, pos, 1);
+ pos++;
+ }
+
+ return {text: res, from: pos, to};
+}
+
+function trimEnd(text: string, delim: string, from: number, to: number): any {
+ let pos = to;
+ let res = text;
+
+ while (pos > from) {
+ if (scanDelims(res, pos).can_close) {
+ break;
+ }
+
+ res = tuiShiftDelim(res, delim, pos, -1);
+ pos--;
+ }
+
+ return {text: res, from, to: pos};
+}
+
+export function tuiTrimInline(
+ text: string,
+ delim: string,
+ from: number,
+ to: number,
+): string {
+ let state = {
+ text,
+ from,
+ to,
+ };
+
+ state = trimStart(state.text, delim, state.from, state.to);
+ state = trimEnd(state.text, delim, state.from, state.to);
+
+ if (state.to - state.from < delim.length + 1) {
+ state.text =
+ state.text.slice(0, Math.max(0, state.from)) +
+ state.text.slice(Math.max(0, state.to + delim.length));
+ }
+
+ return state.text;
+}
+
+export function tuiShiftDelim(
+ text: string,
+ delim: string,
+ start: number,
+ offset: number,
+): string {
+ let res =
+ text.slice(0, Math.max(0, start)) + text.slice(Math.max(0, start + delim.length));
+
+ res =
+ res.slice(0, Math.max(0, start + offset)) +
+ delim +
+ res.slice(Math.max(0, start + offset));
+
+ return res;
+}
diff --git a/projects/tui-editor/src/extensions/markdown/util/prosemirror.ts b/projects/tui-editor/src/extensions/markdown/util/prosemirror.ts
new file mode 100644
index 000000000..512b030e0
--- /dev/null
+++ b/projects/tui-editor/src/extensions/markdown/util/prosemirror.ts
@@ -0,0 +1,3 @@
+export function tuiChildNodes(node: any): any[] {
+ return node?.content?.content ?? [];
+}
diff --git a/projects/tui-editor/src/index.ts b/projects/tui-editor/src/index.ts
index 11a515fdd..af7413e11 100644
--- a/projects/tui-editor/src/index.ts
+++ b/projects/tui-editor/src/index.ts
@@ -63,6 +63,7 @@ export * from './extensions/image-editor/image-editor.options';
export * from './extensions/indent-outdent';
export * from './extensions/jump-anchor';
export * from './extensions/link';
+export * from './extensions/markdown';
export * from './extensions/media';
export * from './extensions/mention';
export * from './extensions/starter-kit';