Skip to content

Commit e3f6fbc

Browse files
authored
feat(core): autoconvert html to md when pasting in markdown mode (#476)
1 parent 8b2047b commit e3f6fbc

File tree

36 files changed

+1481
-431
lines changed

36 files changed

+1481
-431
lines changed

package-lock.json

Lines changed: 529 additions & 405 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,7 @@
226226
"@types/gulp": "4.0.9",
227227
"@types/gulp-sass": "5.0.0",
228228
"@types/jest": "^27.0.3",
229+
"@types/jsdom": "21.1.7",
229230
"@types/katex": "0.16.7",
230231
"@types/lodash": "^4.14.177",
231232
"@types/markdown-it-emoji": "2.0.2",
@@ -246,6 +247,7 @@
246247
"identity-obj-proxy": "^3.0.0",
247248
"jest": "^27.3.1",
248249
"jest-css-modules": "^2.1.0",
250+
"jsdom": "25.0.1",
249251
"lowlight": "3.0.0",
250252
"markdown-it-testgen": "^0.1.6",
251253
"mermaid": "10.9.0",

src/bundle/Editor.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ export interface EditorInt
9292
readonly renderStorage: RenderStorage<ReactNode>;
9393
readonly fileUploadHandler?: FileUploadHandler;
9494
readonly needToSetDimensionsForUploadedImages: boolean;
95+
readonly disableHTMLParsingInMd?: boolean;
9596

9697
readonly renderPreview?: RenderPreview;
9798

@@ -273,6 +274,7 @@ export class EditorImpl extends SafeEventEmitter<EventMapInt> implements EditorI
273274
uploadHandler: this.fileUploadHandler,
274275
parseInsertedUrlAsImage: this.parseInsertedUrlAsImage,
275276
needImageDimensions: this.needToSetDimensionsForUploadedImages,
277+
parseHtmlOnPaste: this.#markupConfig.parseHtmlOnPaste,
276278
enableNewImageSizeCalculation: this.enableNewImageSizeCalculation,
277279
extensions: this.#markupConfig.extensions,
278280
disabledExtensions: this.#markupConfig.disabledExtensions,

src/bundle/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,8 @@ export type MarkdownEditorMarkupConfig = {
120120
keymaps?: CreateCodemirrorParams['keymaps'];
121121
/** Overrides the default placeholder content. */
122122
placeholder?: CreateCodemirrorParams['placeholder'];
123+
/** Enable HTML parsing when pasting content. */
124+
parseHtmlOnPaste?: boolean;
123125
/**
124126
* Additional language data for markdown language in codemirror.
125127
* Can be used to configure additional autocompletions and others.

src/extensions/behavior/Clipboard/utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export enum DataTransferType {
33
Text = 'text/plain',
44
Html = 'text/html',
55
Yfm = 'text/yfm', // self
6+
Rtf = 'text/rtf', // Safari, WebStorm/Intelij
67
UriList = 'text/uri-list',
78
VSCodeData = 'vscode-editor-data',
89
Files = 'Files',
Lines changed: 2 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type {EditorProps} from 'prosemirror-view';
22

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

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

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

2727
if (isVSCode(data)) {
2828
editor = 'vscode';
29-
mode = tryCatch<VSCodeData>(() => JSON.parse(data.getData(DataTransferType.VSCodeData)))
30-
?.mode;
29+
mode = tryParseVSCodeData(data)?.mode;
3130
} else return null;
3231

3332
return {editor, mode, value: data.getData(DataTransferType.Text)};
3433
}
3534
return null;
3635
}
37-
38-
type VSCodeData = {
39-
version: number;
40-
isFromEmptySelection: boolean;
41-
multicursorText: null | string;
42-
mode: string;
43-
[key: string]: unknown;
44-
};
45-
46-
function isVSCode(data: DataTransfer): boolean {
47-
return data.types.includes(DataTransferType.VSCodeData);
48-
}
49-
50-
function tryCatch<R>(fn: () => R): R | undefined {
51-
try {
52-
return fn();
53-
} catch (e) {
54-
console.error(e);
55-
}
56-
return undefined;
57-
}

src/markup/codemirror/create.ts

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,10 @@ import type {ParseInsertedUrlAsImage} from '../../bundle';
1515
import type {EventMap} from '../../bundle/Editor';
1616
import {ActionName} from '../../bundle/config/action-names';
1717
import type {ReactRenderStorage} from '../../extensions';
18-
import {DataTransferType} from '../../extensions/behavior/Clipboard/utils';
1918
import {logger} from '../../logger';
2019
import {Action as A, formatter as f} from '../../shortcuts';
2120
import type {Receiver} from '../../utils';
21+
import {DataTransferType, shouldSkipHtmlConversion} from '../../utils/clipboard';
2222
import type {DirectiveSyntaxContext} from '../../utils/directive';
2323
import {
2424
insertImages,
@@ -42,6 +42,7 @@ import {
4242
import {DirectiveSyntaxFacet} from './directive-facet';
4343
import {type FileUploadHandler, FileUploadHandlerFacet} from './files-upload-facet';
4444
import {gravityHighlightStyle, gravityTheme} from './gravity';
45+
import {MarkdownConverter} from './html-to-markdown/converters';
4546
import {PairingCharactersExtension} from './pairing-chars';
4647
import {ReactRendererFacet} from './react-facet';
4748
import {SearchPanelPlugin} from './search-plugin/plugin';
@@ -61,6 +62,7 @@ export type CreateCodemirrorParams = {
6162
onScroll: (event: Event) => void;
6263
reactRenderer: ReactRenderStorage;
6364
uploadHandler?: FileUploadHandler;
65+
parseHtmlOnPaste?: boolean;
6466
parseInsertedUrlAsImage?: ParseInsertedUrlAsImage;
6567
needImageDimensions?: boolean;
6668
enableNewImageSizeCalculation?: boolean;
@@ -91,6 +93,7 @@ export function createCodemirror(params: CreateCodemirrorParams) {
9193
extensions: extraExtensions,
9294
placeholder: placeholderContent,
9395
autocompletion: autocompletionConfig,
96+
parseHtmlOnPaste,
9497
parseInsertedUrlAsImage,
9598
directiveSyntax,
9699
} = params;
@@ -157,7 +160,47 @@ export function createCodemirror(params: CreateCodemirrorParams) {
157160
onScroll(event);
158161
},
159162
paste(event, editor) {
160-
if (event.clipboardData && parseInsertedUrlAsImage) {
163+
if (!event.clipboardData) return;
164+
165+
// if clipboard contains YFM content - avoid any meddling with pasted content
166+
// since text/yfm will contain valid markdown
167+
const yfmContent = event.clipboardData.getData(DataTransferType.Yfm);
168+
if (yfmContent) {
169+
event.preventDefault();
170+
editor.dispatch(editor.state.replaceSelection(yfmContent));
171+
return;
172+
}
173+
174+
// checking if a copy buffer content is suitable for convertion
175+
const shouldSkipHtml = shouldSkipHtmlConversion(event.clipboardData);
176+
177+
// if we have text/html inside copy/paste buffer
178+
const htmlContent = event.clipboardData.getData(DataTransferType.Html);
179+
// if we pasting markdown from VsCode we need skip html transformation
180+
if (htmlContent && parseHtmlOnPaste && !shouldSkipHtml) {
181+
let parsedMarkdownMarkup: string | undefined;
182+
try {
183+
const parser = new DOMParser();
184+
const htmlDoc = parser.parseFromString(htmlContent, 'text/html');
185+
186+
const converter = new MarkdownConverter();
187+
parsedMarkdownMarkup = converter.processNode(htmlDoc.body).trim();
188+
} catch (e) {
189+
// The code is pretty new and there might be random issues we haven't caught yet,
190+
// especially with invalid HTML or weird DOM parsing errors.
191+
// If something goes wrong, I just want to fall back to the "default pasting"
192+
// rather than break the entire experience for the user.
193+
logger.error(e);
194+
}
195+
196+
if (parsedMarkdownMarkup !== undefined) {
197+
event.preventDefault();
198+
editor.dispatch(editor.state.replaceSelection(parsedMarkdownMarkup));
199+
return;
200+
}
201+
}
202+
203+
if (parseInsertedUrlAsImage) {
161204
const {imageUrl, title} =
162205
parseInsertedUrlAsImage(
163206
event.clipboardData.getData(DataTransferType.Text) ?? '',
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
4+
import {JSDOM} from 'jsdom';
5+
6+
import {MarkdownConverter} from '../converters';
7+
8+
describe('HTML to Markdown Converter', () => {
9+
const fixturesPath = path.join(__dirname, './fixtures');
10+
const testCases = fs.readdirSync(fixturesPath);
11+
let converter: MarkdownConverter;
12+
13+
beforeEach(() => {
14+
converter = new MarkdownConverter();
15+
});
16+
17+
testCases.forEach((testCase) => {
18+
it(`should convert ${testCase} correctly`, () => {
19+
const inputPath = path.join(fixturesPath, testCase, 'input.html');
20+
const outputPath = path.join(fixturesPath, testCase, 'output.md');
21+
22+
const inputHtml = fs.readFileSync(inputPath, 'utf-8');
23+
const expectedOutput = fs.readFileSync(outputPath, 'utf-8').trim();
24+
25+
// Create a proper HTML document
26+
const dom = new JSDOM(`
27+
<!DOCTYPE html>
28+
<html>
29+
<body>
30+
${inputHtml}
31+
</body>
32+
</html>
33+
`);
34+
35+
// Process the content inside body
36+
const result = converter.processNode(dom.window.document.body).trim();
37+
38+
// Compare the result with expected output
39+
expect(result).toBe(expectedOutput);
40+
});
41+
});
42+
});
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
<p>This is a simple paragraph.</p>
2+
<p>This is another paragraph with some text.</p>
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
This is a simple paragraph.
2+
3+
This is another paragraph with some text.

0 commit comments

Comments
 (0)