Skip to content

Commit

Permalink
feat(core): autoconvert html to md when pasting in markdown mode (#476)
Browse files Browse the repository at this point in the history
  • Loading branch information
obenjiro authored Nov 27, 2024
1 parent 8b2047b commit e3f6fbc
Show file tree
Hide file tree
Showing 36 changed files with 1,481 additions and 431 deletions.
934 changes: 529 additions & 405 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@
"@types/gulp": "4.0.9",
"@types/gulp-sass": "5.0.0",
"@types/jest": "^27.0.3",
"@types/jsdom": "21.1.7",
"@types/katex": "0.16.7",
"@types/lodash": "^4.14.177",
"@types/markdown-it-emoji": "2.0.2",
Expand All @@ -246,6 +247,7 @@
"identity-obj-proxy": "^3.0.0",
"jest": "^27.3.1",
"jest-css-modules": "^2.1.0",
"jsdom": "25.0.1",
"lowlight": "3.0.0",
"markdown-it-testgen": "^0.1.6",
"mermaid": "10.9.0",
Expand Down
2 changes: 2 additions & 0 deletions src/bundle/Editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export interface EditorInt
readonly renderStorage: RenderStorage<ReactNode>;
readonly fileUploadHandler?: FileUploadHandler;
readonly needToSetDimensionsForUploadedImages: boolean;
readonly disableHTMLParsingInMd?: boolean;

readonly renderPreview?: RenderPreview;

Expand Down Expand Up @@ -273,6 +274,7 @@ export class EditorImpl extends SafeEventEmitter<EventMapInt> implements EditorI
uploadHandler: this.fileUploadHandler,
parseInsertedUrlAsImage: this.parseInsertedUrlAsImage,
needImageDimensions: this.needToSetDimensionsForUploadedImages,
parseHtmlOnPaste: this.#markupConfig.parseHtmlOnPaste,
enableNewImageSizeCalculation: this.enableNewImageSizeCalculation,
extensions: this.#markupConfig.extensions,
disabledExtensions: this.#markupConfig.disabledExtensions,
Expand Down
2 changes: 2 additions & 0 deletions src/bundle/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ export type MarkdownEditorMarkupConfig = {
keymaps?: CreateCodemirrorParams['keymaps'];
/** Overrides the default placeholder content. */
placeholder?: CreateCodemirrorParams['placeholder'];
/** Enable HTML parsing when pasting content. */
parseHtmlOnPaste?: boolean;
/**
* Additional language data for markdown language in codemirror.
* Can be used to configure additional autocompletions and others.
Expand Down
1 change: 1 addition & 0 deletions src/extensions/behavior/Clipboard/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export enum DataTransferType {
Text = 'text/plain',
Html = 'text/html',
Yfm = 'text/yfm', // self
Rtf = 'text/rtf', // Safari, WebStorm/Intelij
UriList = 'text/uri-list',
VSCodeData = 'vscode-editor-data',
Files = 'Files',
Expand Down
26 changes: 2 additions & 24 deletions src/extensions/markdown/CodeBlock/handle-paste.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type {EditorProps} from 'prosemirror-view';

import {DataTransferType} from '../../behavior/Clipboard/utils';
import {DataTransferType, isVSCode, tryParseVSCodeData} from '../../../utils/clipboard';

import {cbType, codeBlockLangAttr} from './const';

Expand All @@ -26,32 +26,10 @@ function getCodeData(data: DataTransfer): null | {editor: string; mode?: string;

if (isVSCode(data)) {
editor = 'vscode';
mode = tryCatch<VSCodeData>(() => JSON.parse(data.getData(DataTransferType.VSCodeData)))
?.mode;
mode = tryParseVSCodeData(data)?.mode;
} else return null;

return {editor, mode, value: data.getData(DataTransferType.Text)};
}
return null;
}

type VSCodeData = {
version: number;
isFromEmptySelection: boolean;
multicursorText: null | string;
mode: string;
[key: string]: unknown;
};

function isVSCode(data: DataTransfer): boolean {
return data.types.includes(DataTransferType.VSCodeData);
}

function tryCatch<R>(fn: () => R): R | undefined {
try {
return fn();
} catch (e) {
console.error(e);
}
return undefined;
}
47 changes: 45 additions & 2 deletions src/markup/codemirror/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ import type {ParseInsertedUrlAsImage} from '../../bundle';
import type {EventMap} from '../../bundle/Editor';
import {ActionName} from '../../bundle/config/action-names';
import type {ReactRenderStorage} from '../../extensions';
import {DataTransferType} from '../../extensions/behavior/Clipboard/utils';
import {logger} from '../../logger';
import {Action as A, formatter as f} from '../../shortcuts';
import type {Receiver} from '../../utils';
import {DataTransferType, shouldSkipHtmlConversion} from '../../utils/clipboard';
import type {DirectiveSyntaxContext} from '../../utils/directive';
import {
insertImages,
Expand All @@ -42,6 +42,7 @@ import {
import {DirectiveSyntaxFacet} from './directive-facet';
import {type FileUploadHandler, FileUploadHandlerFacet} from './files-upload-facet';
import {gravityHighlightStyle, gravityTheme} from './gravity';
import {MarkdownConverter} from './html-to-markdown/converters';
import {PairingCharactersExtension} from './pairing-chars';
import {ReactRendererFacet} from './react-facet';
import {SearchPanelPlugin} from './search-plugin/plugin';
Expand All @@ -61,6 +62,7 @@ export type CreateCodemirrorParams = {
onScroll: (event: Event) => void;
reactRenderer: ReactRenderStorage;
uploadHandler?: FileUploadHandler;
parseHtmlOnPaste?: boolean;
parseInsertedUrlAsImage?: ParseInsertedUrlAsImage;
needImageDimensions?: boolean;
enableNewImageSizeCalculation?: boolean;
Expand Down Expand Up @@ -91,6 +93,7 @@ export function createCodemirror(params: CreateCodemirrorParams) {
extensions: extraExtensions,
placeholder: placeholderContent,
autocompletion: autocompletionConfig,
parseHtmlOnPaste,
parseInsertedUrlAsImage,
directiveSyntax,
} = params;
Expand Down Expand Up @@ -157,7 +160,47 @@ export function createCodemirror(params: CreateCodemirrorParams) {
onScroll(event);
},
paste(event, editor) {
if (event.clipboardData && parseInsertedUrlAsImage) {
if (!event.clipboardData) return;

// if clipboard contains YFM content - avoid any meddling with pasted content
// since text/yfm will contain valid markdown
const yfmContent = event.clipboardData.getData(DataTransferType.Yfm);
if (yfmContent) {
event.preventDefault();
editor.dispatch(editor.state.replaceSelection(yfmContent));
return;
}

// checking if a copy buffer content is suitable for convertion
const shouldSkipHtml = shouldSkipHtmlConversion(event.clipboardData);

// if we have text/html inside copy/paste buffer
const htmlContent = event.clipboardData.getData(DataTransferType.Html);
// if we pasting markdown from VsCode we need skip html transformation
if (htmlContent && parseHtmlOnPaste && !shouldSkipHtml) {
let parsedMarkdownMarkup: string | undefined;
try {
const parser = new DOMParser();
const htmlDoc = parser.parseFromString(htmlContent, 'text/html');

const converter = new MarkdownConverter();
parsedMarkdownMarkup = converter.processNode(htmlDoc.body).trim();
} catch (e) {
// The code is pretty new and there might be random issues we haven't caught yet,
// especially with invalid HTML or weird DOM parsing errors.
// If something goes wrong, I just want to fall back to the "default pasting"
// rather than break the entire experience for the user.
logger.error(e);
}

if (parsedMarkdownMarkup !== undefined) {
event.preventDefault();
editor.dispatch(editor.state.replaceSelection(parsedMarkdownMarkup));
return;
}
}

if (parseInsertedUrlAsImage) {
const {imageUrl, title} =
parseInsertedUrlAsImage(
event.clipboardData.getData(DataTransferType.Text) ?? '',
Expand Down
42 changes: 42 additions & 0 deletions src/markup/codemirror/html-to-markdown/__tests__/converter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import fs from 'node:fs';
import path from 'node:path';

import {JSDOM} from 'jsdom';

import {MarkdownConverter} from '../converters';

describe('HTML to Markdown Converter', () => {
const fixturesPath = path.join(__dirname, './fixtures');
const testCases = fs.readdirSync(fixturesPath);
let converter: MarkdownConverter;

beforeEach(() => {
converter = new MarkdownConverter();
});

testCases.forEach((testCase) => {
it(`should convert ${testCase} correctly`, () => {
const inputPath = path.join(fixturesPath, testCase, 'input.html');
const outputPath = path.join(fixturesPath, testCase, 'output.md');

const inputHtml = fs.readFileSync(inputPath, 'utf-8');
const expectedOutput = fs.readFileSync(outputPath, 'utf-8').trim();

// Create a proper HTML document
const dom = new JSDOM(`
<!DOCTYPE html>
<html>
<body>
${inputHtml}
</body>
</html>
`);

// Process the content inside body
const result = converter.processNode(dom.window.document.body).trim();

// Compare the result with expected output
expect(result).toBe(expectedOutput);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<p>This is a simple paragraph.</p>
<p>This is another paragraph with some text.</p>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
This is a simple paragraph.

This is another paragraph with some text.
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<p>Inline code with escape <code>test`</code></p>

<pre data-language="plaintext"><code class="hljs plaintext">import React from 'react';
import { useMarkdownEditor, MarkdownEditorView } from '@gravity-ui/markdown-editor';
import { toaster } from '@gravity-ui/uikit/toaster-singleton-react-18';

function Editor({ onSubmit }) {
const editor = useMarkdownEditor({ allowHTML: false });

React.useEffect(() =&gt; {
function submitHandler() {
// Serialize current content to markdown markup
const value = editor.getValue();
onSubmit(value);
}

editor.on('submit', submitHandler);
return () =&gt; {
editor.off('submit', submitHandler);
};
}, [onSubmit]);

return &lt;MarkdownEditorView stickyToolbar autofocus toaster={toaster} editor={editor} /&gt;;
}</code></pre>
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
Inline code with escape `` test` ``

```
import React from 'react';
import { useMarkdownEditor, MarkdownEditorView } from '@gravity-ui/markdown-editor';
import { toaster } from '@gravity-ui/uikit/toaster-singleton-react-18';
function Editor({ onSubmit }) {
const editor = useMarkdownEditor({ allowHTML: false });
React.useEffect(() => {
function submitHandler() {
// Serialize current content to markdown markup
const value = editor.getValue();
onSubmit(value);
}
editor.on('submit', submitHandler);
return () => {
editor.off('submit', submitHandler);
};
}, [onSubmit]);
return <MarkdownEditorView stickyToolbar autofocus toaster={toaster} editor={editor} />;
}
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<p>This text has <strong>bold</strong> and <em>italic</em> formatting.</p>
<p>This text uses <b>bold</b> and <i>italic</i> tags.</p>
<p>This text uses <span style="font-weight: 600;">CSS bold</span> and <span style="font-style: italic;">CSS italic</span>.</p>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
This text has **bold** and *italic* formatting.

This text uses **bold** and *italic* tags.

This text uses **CSS bold** and *CSS italic*.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<h1>Main Title</h1><a href="https://example.com">Link</a>
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Main Title
[Link](https://example.com/ "Link")
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<h1>Main Title</h1>
<p>Some content.</p>
<h2>Subtitle</h2>
<p>More content.</p>
<h3>Section 3</h3>
<h4>Section 4</h4>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Main Title
Some content.

## Subtitle
More content.

### Section 3
#### Section 4
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<img src="https://github.com/user-attachments/assets/e2cdda03-fa1d-48fd-91e7-88d935f8bb9b" alt="nature">
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
![nature](https://github.com/user-attachments/assets/e2cdda03-fa1d-48fd-91e7-88d935f8bb9b)
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<p>A link inside the <span style="font-style: italic;"><a href="https://example.com">span</a></span>.</p>

<a href="https://en.wikipedia.org/wiki/Price_markdown" title="Price markdown" style="text-decoration: underline; color: var(--color-progressive,#36c); background: none rgb(255, 255, 255); border-radius: 2px; outline-color: var(--outline-color-progressive--focus,#36c); overflow-wrap: break-word; font-family: sans-serif; font-size: 16px; font-style: italic; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal;">Price markdown</a>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
A link inside the *[span](https://example.com/ "span")*.

[*Price markdown*](https://en.wikipedia.org/wiki/Price_markdown "Price markdown")
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<p>Here's a <a href="https://example.com">simple link</a>.</p>
<p>This link has <a href="https://example.com"><b>bold text</b></a>.</p>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Here's a [simple link](https://example.com/ "simple link").

This link has [**bold text**](https://example.com/ "bold text").
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<ol>
<li>First item</li>
<li>Second item</li>
<li>Third item</li>
</ol>
<ul>
<li>Item A</li>
<li>Item B</li>
<li>Item C</li>
</ul>
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
1. First item
2. Second item
3. Third item
- Item A
- Item B
- Item C
Loading

0 comments on commit e3f6fbc

Please sign in to comment.