Skip to content

Commit 1dd60c6

Browse files
authored
refactor(CodeBlock): store lowlight instance in separate plugin state (#1033)
1 parent 27d023d commit 1dd60c6

File tree

4 files changed

+156
-107
lines changed

4 files changed

+156
-107
lines changed

packages/editor/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
"test:cov": "jest --coverage",
2727
"test:watch": "jest --watchAll",
2828
"test:esbuild": "node tests/esbuild-test/esbuild-tester.js",
29-
"test:circular-deps": "node scripts/check-circular-deps.js 48",
29+
"test:circular-deps": "node scripts/check-circular-deps.js 47",
3030
"prepack": "cp ../../README.md ./README.md",
3131
"postpack": "rm -f ./README.md",
3232
"prepublishOnly": "pnpm run lint && pnpm run clean && pnpm run build"
Lines changed: 32 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,11 @@
1-
import type {Options} from '@diplodoc/transform';
2-
// importing only type, because lowlight and highlight.js is optional deps
3-
import type HLJS from 'highlight.js/lib/core';
4-
import type {createLowlight} from 'lowlight' with {'resolution-mode': 'import'};
51
import type {Node} from 'prosemirror-model';
62
import {Plugin, PluginKey} from 'prosemirror-state';
73
// @ts-ignore // TODO: fix cjs build
84
import {findChildrenByType} from 'prosemirror-utils';
9-
import {Decoration, DecorationSet, type EditorView} from 'prosemirror-view';
5+
import {Decoration, DecorationSet} from 'prosemirror-view';
6+
7+
import type {ExtensionAuto} from '#core';
108

11-
import type {ExtensionAuto} from '../../../../core';
12-
import {capitalize} from '../../../../lodash';
13-
import {globalLogger} from '../../../../logger';
149
import {
1510
CodeBlockNodeAttr,
1611
type LineNumbersOptions,
@@ -20,22 +15,21 @@ import {
2015

2116
import {CodeBlockNodeView} from './CodeBlockNodeView';
2217
import {codeLangSelectTooltipViewCreator} from './TooltipPlugin';
23-
import {PlainTextLang} from './const';
18+
import {
19+
type HighlightLangMap,
20+
type LLRoot,
21+
type Lowlight,
22+
codeBlockLangsPlugin,
23+
codeBlockLangsPluginKey,
24+
getCodeBlockLangsState,
25+
} from './plugins/codeBlockLangsPlugin';
2426
import {codeBlockLineNumbersPlugin} from './plugins/codeBlockLineNumbersPlugin';
2527
import {codeBlockLineWrappingPlugin} from './plugins/codeBlockLineWrappingPlugin';
2628
import {processChangedCodeBlocks} from './utils';
2729

2830
import './CodeBlockHighlight.scss';
2931

30-
export type HighlightLangMap = Options['highlightLangs'];
31-
32-
type Lowlight = ReturnType<typeof createLowlight>;
33-
type Root = ReturnType<Lowlight['highlight']>;
34-
35-
type LangSelectItem = {
36-
value: string;
37-
content: string;
38-
};
32+
export type {HighlightLangMap};
3933

4034
const pluginKey = new PluginKey<PluginState>('code_block_highlight');
4135

@@ -58,85 +52,34 @@ export type CodeBlockHighlightOptions = {
5852
};
5953

6054
export const CodeBlockHighlight: ExtensionAuto<CodeBlockHighlightOptions> = (builder, opts) => {
61-
let langs: NonNullable<HighlightLangMap>;
62-
let lowlight: Lowlight;
63-
let hljs: typeof HLJS;
64-
65-
const loadModules = async () => {
66-
try {
67-
hljs = (await import('highlight.js/lib/core')).default;
68-
const low = await import('lowlight');
69-
70-
const all: HighlightLangMap = low.all;
71-
const create: typeof createLowlight = low.createLowlight;
72-
langs = {...all, ...opts.langs};
73-
lowlight = create(langs);
74-
return true;
75-
} catch (e) {
76-
globalLogger.info('Skip code_block highlighting');
77-
builder.logger.log('Skip code_block highlighting');
78-
return false;
79-
}
80-
};
81-
8255
if (opts.lineWrapping?.enabled) builder.addPlugin(codeBlockLineWrappingPlugin);
8356
if (opts.lineNumbers?.enabled) builder.addPlugin(codeBlockLineNumbersPlugin);
8457

85-
builder.addPlugin(() => {
86-
let modulesLoaded = false;
87-
let view: EditorView | null = null;
88-
89-
// empty array by default, but is filled after loading modules
90-
const selectItems: LangSelectItem[] = [];
91-
const mapping: Record<string, string> = {};
58+
builder.addPlugin(() => codeBlockLangsPlugin(opts.langs, builder.logger));
9259

60+
builder.addPlugin(() => {
9361
// TODO: add TAB key handler
9462
// TODO: Remove constant selection of block
9563
return new Plugin<PluginState>({
9664
key: pluginKey,
9765
state: {
98-
init: (_, state) => {
99-
loadModules().then((loaded) => {
100-
modulesLoaded = loaded;
101-
102-
if (modulesLoaded) {
103-
for (const lang of Object.keys(langs)) {
104-
const defs = langs[lang](hljs);
105-
selectItems.push({
106-
value: lang,
107-
content: defs.name || capitalize(lang),
108-
});
109-
if (defs.aliases) {
110-
for (const alias of defs.aliases) {
111-
mapping[alias] = lang;
112-
}
113-
}
114-
}
115-
116-
selectItems.sort(sortLangs);
117-
118-
if (view && !view.isDestroyed) {
119-
view.dispatch(view.state.tr.setMeta(pluginKey, {modulesLoaded}));
120-
}
121-
}
122-
});
123-
66+
init: (_config, _state) => {
12467
const cache: HighlightCache = new WeakMap();
125-
126-
return {
127-
cache,
128-
decoSet: modulesLoaded
129-
? DecorationSet.empty
130-
: getDecorations(state.doc, cache),
131-
};
68+
return {cache, decoSet: DecorationSet.empty};
13269
},
133-
apply: (tr, {cache, decoSet}) => {
134-
if (!modulesLoaded) {
135-
return {cache, decoSet: DecorationSet.empty};
70+
apply: (tr, {cache, decoSet}, _oldState, newState) => {
71+
const langsUpdate = tr.getMeta(codeBlockLangsPluginKey);
72+
if (langsUpdate?.loaded && langsUpdate.lowlight) {
73+
return {
74+
cache,
75+
decoSet: getDecorations(tr.doc, cache, langsUpdate.lowlight),
76+
};
13677
}
13778

138-
if (tr.getMeta(pluginKey)?.modulesLoaded) {
139-
return {cache, decoSet: getDecorations(tr.doc, cache)};
79+
const {lowlight} = getCodeBlockLangsState(newState);
80+
81+
if (!lowlight) {
82+
return {cache, decoSet: DecorationSet.empty};
14083
}
14184

14285
if (!tr.docChanged) return {cache, decoSet};
@@ -167,9 +110,8 @@ export const CodeBlockHighlight: ExtensionAuto<CodeBlockHighlightOptions> = (bui
167110
return {cache, decoSet};
168111
},
169112
},
170-
view: (v) => {
171-
view = v;
172-
return codeLangSelectTooltipViewCreator(view, selectItems, mapping, {
113+
view: (view) => {
114+
return codeLangSelectTooltipViewCreator(view, {
173115
showCodeWrapping: Boolean(opts.lineWrapping?.enabled),
174116
showLineNumbers: Boolean(opts.lineNumbers?.enabled),
175117
});
@@ -185,11 +127,7 @@ export const CodeBlockHighlight: ExtensionAuto<CodeBlockHighlightOptions> = (bui
185127
});
186128
});
187129

188-
function getDecorations(doc: Node, cache: HighlightCache) {
189-
if (!lowlight) {
190-
return DecorationSet.empty;
191-
}
192-
130+
function getDecorations(doc: Node, cache: HighlightCache, lowlight: Lowlight) {
193131
const decos: Decoration[] = [];
194132

195133
for (const {node, pos} of findChildrenByType(doc, codeBlockType(doc.type.schema), true)) {
@@ -233,7 +171,7 @@ function renderTree(parsedNodes: HighlightParsedTree, from: number): Decoration[
233171
}
234172

235173
function parseNodes(
236-
nodes: Root['children'],
174+
nodes: LLRoot['children'],
237175
className: readonly string[] = [],
238176
): HighlightParsedTree {
239177
const result: HighlightParsedTree = [];
@@ -242,7 +180,7 @@ function parseNodes(
242180
}
243181

244182
function collectNodes(
245-
nodes: Root['children'],
183+
nodes: LLRoot['children'],
246184
className: readonly string[],
247185
result: HighlightParsedTree,
248186
): void {
@@ -258,10 +196,3 @@ function collectNodes(
258196
}
259197
}
260198
}
261-
262-
function sortLangs(a: LangSelectItem, b: LangSelectItem): number {
263-
// plaintext always goes first
264-
if (a.value === PlainTextLang) return -1;
265-
if (b.value === PlainTextLang) return 1;
266-
return 0;
267-
}

packages/editor/src/extensions/markdown/CodeBlock/CodeBlockHighlight/TooltipPlugin/index.tsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
1-
import type {SelectOption} from '@gravity-ui/uikit';
2-
31
import type {EditorView} from '#pm/view';
42
import {BaseTooltipPluginView} from 'src/plugins/BaseTooltip';
53

64
import {codeBlockType} from '../../CodeBlockSpecs';
5+
import {getCodeBlockLangsState} from '../plugins/codeBlockLangsPlugin';
76

87
import {CodeBlockToolbar} from './CodeBlockToolbar';
98

@@ -16,21 +15,21 @@ type Options = {
1615

1716
export const codeLangSelectTooltipViewCreator = (
1817
view: EditorView,
19-
langItems: SelectOption[],
20-
mapping: Record<string, string> = {},
2118
{showCodeWrapping, showLineNumbers}: Options,
2219
) => {
2320
return new BaseTooltipPluginView(view, {
2421
idPrefix: 'code-block-tooltip',
2522
nodeType: codeBlockType(view.state.schema),
2623
popupPlacement: ['bottom', 'top'],
2724
content: (view, {node, pos}, _onChange, _forceEdit, _onOutsideClick, rerender) => {
25+
const {langItems, aliasMapping} = getCodeBlockLangsState(view.state);
26+
2827
return (
2928
<CodeBlockToolbar
3029
pos={pos}
3130
node={node}
3231
editorView={view}
33-
mapping={mapping}
32+
mapping={aliasMapping}
3433
langItems={langItems}
3534
rerenderTooltip={rerender}
3635
showLineNumbers={showLineNumbers}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import type {Options} from '@diplodoc/transform';
2+
import type {createLowlight} from 'lowlight' with {'resolution-mode': 'import'};
3+
4+
import type {EditorState} from '#pm/state';
5+
import {Plugin, PluginKey} from '#pm/state';
6+
import type {EditorView} from '#pm/view';
7+
import {capitalize} from 'src/lodash';
8+
import {globalLogger} from 'src/logger';
9+
10+
import {PlainTextLang} from '../const';
11+
12+
export type HighlightLangMap = Options['highlightLangs'];
13+
14+
export type Lowlight = ReturnType<typeof createLowlight>;
15+
export type LLRoot = ReturnType<Lowlight['highlight']>;
16+
17+
type LangItem = {
18+
value: string;
19+
content: string;
20+
};
21+
22+
type CodeBlockLangsState = {
23+
langItems: LangItem[];
24+
aliasMapping: Record<string, string>;
25+
lowlight: Lowlight | null;
26+
loaded: boolean;
27+
};
28+
29+
export const codeBlockLangsPluginKey = new PluginKey<CodeBlockLangsState>('code_block_langs');
30+
31+
const defaultState: CodeBlockLangsState = {
32+
langItems: [],
33+
aliasMapping: {},
34+
lowlight: null,
35+
loaded: false,
36+
};
37+
38+
export function getCodeBlockLangsState(state: EditorState): CodeBlockLangsState {
39+
return codeBlockLangsPluginKey.getState(state) ?? defaultState;
40+
}
41+
42+
export function codeBlockLangsPlugin(
43+
langsConfig: HighlightLangMap | undefined,
44+
logger: {log: (msg: string) => void},
45+
) {
46+
return new Plugin<CodeBlockLangsState>({
47+
key: codeBlockLangsPluginKey,
48+
state: {
49+
init: (_config, _state) => {
50+
return defaultState;
51+
},
52+
apply: (tr, state) => {
53+
const meta = tr.getMeta(codeBlockLangsPluginKey);
54+
if (meta) return meta as CodeBlockLangsState;
55+
return state;
56+
},
57+
},
58+
view: (view: EditorView) => {
59+
loadAndInit(view, langsConfig, logger);
60+
return {};
61+
},
62+
});
63+
}
64+
65+
async function loadAndInit(
66+
view: EditorView,
67+
langsConfig: HighlightLangMap | undefined,
68+
logger: {log: (msg: string) => void},
69+
) {
70+
try {
71+
const [{default: hljs}, low] = await Promise.all([
72+
import('highlight.js/lib/core'),
73+
import('lowlight'),
74+
]);
75+
76+
const all: HighlightLangMap = low.all;
77+
const create: typeof createLowlight = low.createLowlight;
78+
const langs: NonNullable<HighlightLangMap> = {...all, ...langsConfig};
79+
const lowlight = create(langs);
80+
81+
const langItems: LangItem[] = [];
82+
const aliasMapping: Record<string, string> = {};
83+
84+
for (const lang of Object.keys(langs)) {
85+
const defs = langs[lang](hljs);
86+
langItems.push({
87+
value: lang,
88+
content: defs.name || capitalize(lang),
89+
});
90+
if (defs.aliases) {
91+
for (const alias of defs.aliases) {
92+
aliasMapping[alias] = lang;
93+
}
94+
}
95+
}
96+
97+
langItems.sort(sortLangs);
98+
99+
if (!view.isDestroyed) {
100+
const newState: CodeBlockLangsState = {
101+
langItems,
102+
aliasMapping,
103+
lowlight,
104+
loaded: true,
105+
};
106+
view.dispatch(view.state.tr.setMeta(codeBlockLangsPluginKey, newState));
107+
}
108+
} catch (e) {
109+
globalLogger.info('Skip code_block highlighting');
110+
logger.log('Skip code_block highlighting');
111+
}
112+
}
113+
114+
function sortLangs(a: LangItem, b: LangItem): number {
115+
// plaintext always goes first
116+
if (a.value === PlainTextLang) return -1;
117+
if (b.value === PlainTextLang) return 1;
118+
return 0;
119+
}

0 commit comments

Comments
 (0)