Skip to content

Commit c3c7a7f

Browse files
authored
feat(Math): parse TeX formulas when pasting from code editor and paste them as math block (#888)
1 parent cf6c9e4 commit c3c7a7f

File tree

5 files changed

+165
-0
lines changed

5 files changed

+165
-0
lines changed

src/extensions/additional/Math/Math.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {ExtensionsManager} from '../../../core';
55
import {BaseNode, BaseSchemaSpecs} from '../../base/specs';
66

77
import {MathNode, MathSpecs} from './MathSpecs';
8+
import {isLatexMode, parseLatexFormulas} from './utils';
89

910
const {
1011
schema,
@@ -47,3 +48,46 @@ describe('Math extension', () => {
4748
same(`$$${formula}$$\n\n`, doc(mathB(formula)));
4849
});
4950
});
51+
52+
describe('latex-paste-plugin utilities', () => {
53+
describe('isLatexMode', () => {
54+
it('should return true for tex/latex modes', () => {
55+
expect(isLatexMode('tex')).toBe(true);
56+
expect(isLatexMode('latex')).toBe(true);
57+
expect(isLatexMode('bibtex')).toBe(true);
58+
});
59+
60+
it('should return false for non-latex modes', () => {
61+
expect(isLatexMode('javascript')).toBe(false);
62+
expect(isLatexMode('python')).toBe(false);
63+
expect(isLatexMode(undefined)).toBe(false);
64+
});
65+
});
66+
67+
describe('parseLatexFormulas', () => {
68+
it('should split formulas by double newlines', () => {
69+
const input = 'E = mc^2\n\ne^{i\\pi} + 1 = 0';
70+
const result = parseLatexFormulas(input);
71+
expect(result).toEqual(['E = mc^2', 'e^{i\\pi} + 1 = 0']);
72+
});
73+
74+
it('should preserve comment lines starting with %', () => {
75+
const input = `% Einstein equation
76+
E = mc^2
77+
78+
% Euler formula
79+
e^{i\\pi} + 1 = 0`;
80+
const result = parseLatexFormulas(input);
81+
expect(result).toEqual([
82+
'% Einstein equation\nE = mc^2',
83+
'% Euler formula\ne^{i\\pi} + 1 = 0',
84+
]);
85+
});
86+
87+
it('should return comments as formulas', () => {
88+
const input = '% Comment 1\n% Comment 2';
89+
const result = parseLatexFormulas(input);
90+
expect(result).toEqual(['% Comment 1\n% Comment 2']);
91+
});
92+
});
93+
});
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,13 @@
11
export * from './MathSpecs/const';
22
export {mathBType, mathIType} from './MathSpecs';
3+
4+
export const LATEX_MODES = new Set([
5+
'tex',
6+
'latex',
7+
'bibtex',
8+
'doctex',
9+
'latex-expl3',
10+
'pweave',
11+
'jlweave',
12+
'rsweave',
13+
]);

src/extensions/additional/Math/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,14 @@ import {
1414
removeEmptyMathInlineIfCursorIsAtBeginning,
1515
} from './commands';
1616
import {mathBType, mathIType} from './const';
17+
import {latexPastePlugin} from './latex-paste-plugin';
1718
import {type MathNodeViewOptions, mathViewAndEditPlugin} from './view-and-edit';
1819

1920
import './index.scss';
2021

2122
export {MathNode, mathBType, mathIType} from './MathSpecs';
2223
export {MathBlockNodeView, MathInlineNodeView} from './view-and-edit';
24+
export {isLatexMode, parseLatexFormulas} from './utils';
2325

2426
const mathIAction = 'addMathInline';
2527
const mathBAction = 'toMathBlock';
@@ -45,6 +47,7 @@ export const Math: ExtensionAuto<MathOptions> = (builder, opts) => {
4547
}));
4648

4749
builder
50+
.addPlugin(latexPastePlugin, builder.Priority.VeryHigh)
4851
.addPlugin(() =>
4952
mathViewAndEditPlugin({
5053
...opts,
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import {Plugin} from 'prosemirror-state';
2+
3+
import {getLoggerFromState} from '#core';
4+
import {Fragment} from '#pm/model';
5+
6+
import {MathNode} from './const';
7+
import {getLatexData, parseLatexFormulas} from './utils';
8+
9+
export const latexPastePlugin = () =>
10+
new Plugin({
11+
props: {
12+
handleDOMEvents: {
13+
paste(view, e: Event) {
14+
const event = e as ClipboardEvent;
15+
if (!event.clipboardData || view.state.selection.$from.parent.type.spec.code) {
16+
return false;
17+
}
18+
19+
const latexData = getLatexData(event.clipboardData);
20+
if (!latexData) return false;
21+
22+
getLoggerFromState(view.state).event({
23+
domEvent: 'paste',
24+
event: 'paste-latex-from-code-editor',
25+
editor: latexData.editor,
26+
editorMode: latexData.mode,
27+
empty: !latexData.value,
28+
dataTypes: event.clipboardData.types,
29+
});
30+
31+
const {tr, schema} = view.state;
32+
const mathBlockType = schema.nodes[MathNode.Block];
33+
34+
if (!mathBlockType) return false;
35+
36+
if (latexData.value) {
37+
const formulas = parseLatexFormulas(latexData.value);
38+
39+
if (formulas.length > 0) {
40+
const nodes = formulas.map((formula) =>
41+
mathBlockType.create(null, schema.text(formula)),
42+
);
43+
const fragment = Fragment.from(nodes);
44+
tr.replaceWith(tr.selection.from, tr.selection.to, fragment);
45+
} else {
46+
tr.replaceWith(tr.selection.from, tr.selection.to, Fragment.empty);
47+
}
48+
} else {
49+
tr.replaceWith(tr.selection.from, tr.selection.to, Fragment.empty);
50+
}
51+
52+
view.dispatch(tr.scrollIntoView());
53+
e.preventDefault();
54+
return true;
55+
},
56+
},
57+
},
58+
});
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import dd from 'ts-dedent';
2+
3+
import {DataTransferType, isVSCode, tryParseVSCodeData} from 'src/utils/clipboard';
4+
5+
import {LATEX_MODES} from './const';
6+
7+
export function isLatexMode(mode: string | undefined): boolean {
8+
if (!mode) return false;
9+
return LATEX_MODES.has(mode.toLowerCase());
10+
}
11+
12+
export function parseLatexFormulas(content: string): string[] {
13+
const blocks = content.split(/\n\s*\n/);
14+
const formulas: string[] = [];
15+
16+
for (const block of blocks) {
17+
const lines = block
18+
.split('\n')
19+
.map((line) => line.trim())
20+
.filter((line) => line.length > 0);
21+
22+
if (lines.length > 0) {
23+
formulas.push(lines.join('\n'));
24+
}
25+
}
26+
27+
return formulas;
28+
}
29+
30+
export function getLatexData(
31+
data: DataTransfer,
32+
): null | {editor: string; mode: string; value: string} {
33+
if (!data.getData(DataTransferType.Text)) return null;
34+
35+
if (isVSCode(data)) {
36+
const vsCodeData = tryParseVSCodeData(data);
37+
const mode = vsCodeData?.mode;
38+
39+
if (mode && isLatexMode(mode)) {
40+
return {
41+
editor: 'vscode',
42+
mode,
43+
value: dd(data.getData(DataTransferType.Text)),
44+
};
45+
}
46+
}
47+
48+
return null;
49+
}

0 commit comments

Comments
 (0)