Skip to content

Commit

Permalink
feat: add markdown extension (#937)
Browse files Browse the repository at this point in the history
  • Loading branch information
splincode authored Mar 30, 2024
1 parent 9e14bb3 commit df32291
Show file tree
Hide file tree
Showing 45 changed files with 1,611 additions and 4 deletions.
14 changes: 13 additions & 1 deletion projects/demo/src/app/app.pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
},
Expand Down
5 changes: 5 additions & 0 deletions projects/demo/src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
1 change: 1 addition & 0 deletions projects/demo/src/app/constants/demo-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<tui-editor
[formControl]="control"
[tools]="builtInTools"
>
Placeholder
</tui-editor>

<tui-textarea
class="tui-space_top-5"
[ngModel]="markdown"
[style.min-height.rem]="30"
(ngModelChange)="markdown$.next($event)"
>
Markdown
</tui-textarea>
Original file line number Diff line number Diff line change
@@ -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 <p> inside <li> in markdown output
tightListClass: 'tight', // Add class to <ul> allowing you to remove <p> margins when tight
bulletListMarker: '-', // <li> prefix in markdown output
linkify: true, // Create links from "https://..." text
breaks: true, // New lines (\n) in markdown input are converted to <br>
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<string>();

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() ?? '';
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<tui-doc-page
header="Editor"
type="components"
>
<tui-doc-example
id="markdown"
heading="Markdown"
[component]="component1"
[content]="example1"
/>
</tui-doc-page>
17 changes: 17 additions & 0 deletions projects/demo/src/app/pages/processing/markdown-extension/index.ts
Original file line number Diff line number Diff line change
@@ -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'),
};
}
2 changes: 1 addition & 1 deletion projects/demo/src/app/pages/processing/markdown/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
>
<tui-doc-example
id="markdown"
description="You can use any parsing markdown library"
description="You can use any parsing markdown library outside"
heading="Markdown"
[component]="component1"
[content]="example1"
Expand Down
4 changes: 2 additions & 2 deletions projects/tui-editor/src/components/editor/editor.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ export class TuiEditorComponent

public focused = false;

public readonly editorService = inject(TuiTiptapEditorService);

@ViewChild(TuiToolbarComponent)
protected readonly toolbar?: TuiToolbarComponent;

Expand All @@ -111,8 +113,6 @@ export class TuiEditorComponent
.pipe(delay(0), takeUntil(this.destroy$))
.subscribe(() => this.patchContentEditableElement());

protected readonly editorService = inject(TuiTiptapEditorService);

public get editor(): AbstractTuiEditor | null {
return this.editorService.getOriginTiptapEditor() ? this.editorService : null;
}
Expand Down
51 changes: 51 additions & 0 deletions projects/tui-editor/src/extensions/markdown/clipboard/index.ts
Original file line number Diff line number Diff line change
@@ -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,
);
},
},
}),
];
},
});
45 changes: 45 additions & 0 deletions projects/tui-editor/src/extensions/markdown/extensions/all.ts
Original file line number Diff line number Diff line change
@@ -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,
];
Original file line number Diff line number Diff line change
@@ -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
},
},
};
},
});
Original file line number Diff line number Diff line change
@@ -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
},
},
};
},
});
Original file line number Diff line number Diff line change
@@ -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
},
},
};
},
});
Loading

0 comments on commit df32291

Please sign in to comment.