Skip to content

Commit f57f153

Browse files
authored
feat(code-block): support line numbers in code blocks (#879)
1 parent 578c103 commit f57f153

13 files changed

+283
-66
lines changed
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
.yfm.ProseMirror .hljs.show-line-numbers {
2+
display: flex;
3+
4+
white-space: pre;
5+
}
6+
7+
.yfm.ProseMirror pre > code > .yfm-line-numbers > .yfm-line-number {
8+
display: block;
9+
}

src/extensions/markdown/CodeBlock/CodeBlockHighlight/CodeBlockHighlight.ts

Lines changed: 104 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,17 @@ import {Decoration, DecorationSet} from 'prosemirror-view';
1212
import type {ExtensionAuto} from '../../../../core';
1313
import {capitalize} from '../../../../lodash';
1414
import {globalLogger} from '../../../../logger';
15-
import {CodeBlockNodeAttr, codeBlockNodeName, codeBlockType} from '../CodeBlockSpecs';
15+
import {
16+
CodeBlockNodeAttr,
17+
type LineNumbersOptions,
18+
codeBlockNodeName,
19+
codeBlockType,
20+
} from '../CodeBlockSpecs';
1621

1722
import {codeLangSelectTooltipViewCreator} from './TooltipPlugin';
1823

24+
import './CodeBlockHighlight.scss';
25+
1926
export type HighlightLangMap = Options['highlightLangs'];
2027

2128
type Lowlight = ReturnType<typeof createLowlight>;
@@ -29,6 +36,7 @@ type LangSelectItem = {
2936
const key = new PluginKey<DecorationSet>('code_block_highlight');
3037

3138
export type CodeBlockHighlightOptions = {
39+
lineNumbers?: LineNumbersOptions;
3240
langs?: HighlightLangMap;
3341
};
3442

@@ -135,7 +143,13 @@ export const CodeBlockHighlight: ExtensionAuto<CodeBlockHighlightOptions> = (bui
135143
return decos.map(tr.mapping, tr.doc);
136144
},
137145
},
138-
view: (view) => codeLangSelectTooltipViewCreator(view, selectItems, mapping),
146+
view: (view) =>
147+
codeLangSelectTooltipViewCreator(
148+
view,
149+
selectItems,
150+
mapping,
151+
Boolean(opts.lineNumbers?.enabled),
152+
),
139153
props: {
140154
decorations: (state) => {
141155
return key.getState(state);
@@ -151,15 +165,27 @@ export const CodeBlockHighlight: ExtensionAuto<CodeBlockHighlightOptions> = (bui
151165
node.attrs[CodeBlockNodeAttr.Line],
152166
);
153167

154-
const contentDOM = document.createElement('code');
155-
contentDOM.classList.add('hljs');
168+
const code = document.createElement('code');
169+
code.classList.add('hljs');
156170

157171
if (prevLang) {
158172
dom.setAttribute(CodeBlockNodeAttr.Lang, prevLang);
159-
contentDOM.classList.add(prevLang);
173+
code.classList.add(prevLang);
160174
}
161175

162-
dom.append(contentDOM);
176+
const contentDOM = document.createElement('div');
177+
178+
let lineNumbersContainer: HTMLDivElement | undefined;
179+
let prevLineCount = 0;
180+
181+
if (opts.lineNumbers?.enabled) {
182+
const result = manageLineNumbers(node, code);
183+
lineNumbersContainer = result.container;
184+
prevLineCount = result.lineCount;
185+
}
186+
187+
code.append(contentDOM);
188+
dom.append(code);
163189

164190
return {
165191
dom,
@@ -169,10 +195,10 @@ export const CodeBlockHighlight: ExtensionAuto<CodeBlockHighlightOptions> = (bui
169195

170196
const newLang = newNode.attrs[CodeBlockNodeAttr.Lang];
171197
if (prevLang !== newLang) {
172-
contentDOM.className = 'hljs';
198+
code.className = 'hljs';
173199
updateDomAttribute(dom, CodeBlockNodeAttr.Lang, newLang);
174200
if (newLang) {
175-
contentDOM.classList.add(newLang);
201+
code.classList.add(newLang);
176202
}
177203
prevLang = newLang;
178204
}
@@ -183,6 +209,17 @@ export const CodeBlockHighlight: ExtensionAuto<CodeBlockHighlightOptions> = (bui
183209
newNode.attrs[CodeBlockNodeAttr.Line],
184210
);
185211

212+
if (opts.lineNumbers?.enabled) {
213+
const result = manageLineNumbers(
214+
newNode,
215+
code,
216+
lineNumbersContainer,
217+
prevLineCount,
218+
);
219+
lineNumbersContainer = result.container;
220+
prevLineCount = result.lineCount;
221+
}
222+
186223
return true;
187224
},
188225
};
@@ -259,3 +296,62 @@ function updateDomAttribute(elem: Element, attr: string, value: string | null |
259296
elem.removeAttribute(attr);
260297
}
261298
}
299+
300+
function manageLineNumbers(
301+
node: Node,
302+
code: HTMLElement,
303+
prevContainer?: HTMLDivElement,
304+
prevLineCount = 0,
305+
): {container?: HTMLDivElement; lineCount: number} {
306+
const showLineNumbers = node.attrs[CodeBlockNodeAttr.ShowLineNumbers] === 'true';
307+
308+
if (!showLineNumbers) {
309+
if (prevContainer) {
310+
code.removeChild(prevContainer);
311+
code.classList.remove('show-line-numbers');
312+
}
313+
return {container: undefined, lineCount: 0};
314+
}
315+
316+
const lines = node.textContent ? node.textContent.split('\n') : [''];
317+
const currentLineCount = lines.length;
318+
319+
let container = prevContainer;
320+
if (!container) {
321+
container = document.createElement('div');
322+
container.className = 'yfm-line-numbers';
323+
container.contentEditable = 'false';
324+
code.prepend(container);
325+
}
326+
327+
code.classList.add('show-line-numbers');
328+
329+
if (currentLineCount !== prevLineCount) {
330+
const maxDigits = String(currentLineCount).length;
331+
const prevMaxDigits = String(prevLineCount).length;
332+
333+
if (currentLineCount > prevLineCount) {
334+
for (let i = prevLineCount + 1; i <= currentLineCount; i++) {
335+
const lineNumberElement = document.createElement('div');
336+
lineNumberElement.className = 'yfm-line-number';
337+
lineNumberElement.textContent = String(i).padStart(maxDigits, ' ');
338+
container.appendChild(lineNumberElement);
339+
}
340+
} else if (currentLineCount < prevLineCount) {
341+
for (let i = prevLineCount; i > currentLineCount; i--) {
342+
if (container.lastChild) {
343+
container.removeChild(container.lastChild);
344+
}
345+
}
346+
}
347+
348+
if (maxDigits !== prevMaxDigits) {
349+
Array.from(container.children).forEach((lineNumber, index) => {
350+
const lineNum = index + 1;
351+
lineNumber.textContent = String(lineNum).padStart(maxDigits, ' ');
352+
});
353+
}
354+
}
355+
356+
return {container, lineCount: currentLineCount};
357+
}

src/extensions/markdown/CodeBlock/CodeBlockHighlight/TooltipPlugin/TooltipView.scss

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,7 @@
2828
.g-md-code-block__select-button {
2929
margin: auto 0;
3030
}
31+
32+
.g-md-code-block__show-line-numbers {
33+
margin: auto 0;
34+
}
Lines changed: 96 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,24 @@
1+
import type {ChangeEventHandler} from 'react';
2+
13
import {TrashBin} from '@gravity-ui/icons';
2-
import {Select, type SelectOption} from '@gravity-ui/uikit';
4+
import {Checkbox, Select, type SelectOption} from '@gravity-ui/uikit';
35
import type {Node} from 'prosemirror-model';
46
import type {EditorView} from 'prosemirror-view';
57

8+
import {cn} from 'src/classname';
9+
610
import {i18n} from '../../../../../i18n/codeblock';
711
import {i18n as i18nPlaceholder} from '../../../../../i18n/placeholder';
812
import {BaseTooltipPluginView} from '../../../../../plugins/BaseTooltip';
9-
import {Toolbar, ToolbarDataType} from '../../../../../toolbar';
13+
import {Toolbar, type ToolbarData, ToolbarDataType} from '../../../../../toolbar';
1014
import {removeNode} from '../../../../../utils/remove-node';
1115
import {CodeBlockNodeAttr, codeBlockType} from '../../CodeBlockSpecs';
1216

1317
import './TooltipView.scss';
1418

19+
const bCodeBlock = cn('code-block');
20+
const bToolbar = cn('code-block-toolbar');
21+
1522
type CodeMenuProps = {
1623
view: EditorView;
1724
pos: number;
@@ -22,6 +29,7 @@ type CodeMenuProps = {
2229

2330
const CodeMenu: React.FC<CodeMenuProps> = ({view, pos, node, selectItems, mapping}) => {
2431
const lang = node.attrs[CodeBlockNodeAttr.Lang];
32+
const showLineNumbers = node.attrs[CodeBlockNodeAttr.ShowLineNumbers];
2533
const value = mapping[lang] ?? lang;
2634

2735
const handleClick = (type: string) => {
@@ -31,6 +39,7 @@ const CodeMenu: React.FC<CodeMenuProps> = ({view, pos, node, selectItems, mappin
3139
view.dispatch(
3240
view.state.tr.setNodeMarkup(pos, null, {
3341
[CodeBlockNodeAttr.Lang]: type,
42+
[CodeBlockNodeAttr.ShowLineNumbers]: showLineNumbers,
3443
}),
3544
);
3645
};
@@ -45,67 +54,107 @@ const CodeMenu: React.FC<CodeMenuProps> = ({view, pos, node, selectItems, mappin
4554
options={selectItems}
4655
filterable
4756
filterPlaceholder={i18nPlaceholder('select_filter')}
48-
popupClassName="g-md-code-block__select-popup"
49-
className="g-md-code-block__select-button"
57+
popupClassName={bCodeBlock('select-popup')}
58+
className={bCodeBlock('select-button')}
5059
renderEmptyOptions={() => (
51-
<div className="g-md-code-block__select-empty">{i18n('empty_option')}</div>
60+
<div className={bCodeBlock('select-empty')}>{i18n('empty_option')}</div>
5261
)}
5362
// TODO: in onOpenChange return focus to view.dom after press Esc in Select
5463
// after https://github.com/gravity-ui/uikit/issues/2075
5564
/>
5665
);
5766
};
5867

68+
type ShowLineNumbersProps = {
69+
view: EditorView;
70+
pos: number;
71+
node: Node;
72+
};
73+
74+
const ShowLineNumbers: React.FC<ShowLineNumbersProps> = ({view, pos, node}) => {
75+
const lang = node.attrs[CodeBlockNodeAttr.Lang];
76+
const showLineNumbers = node.attrs[CodeBlockNodeAttr.ShowLineNumbers] === 'true';
77+
78+
const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => {
79+
view.dispatch(
80+
view.state.tr.setNodeMarkup(pos, null, {
81+
[CodeBlockNodeAttr.Lang]: lang,
82+
[CodeBlockNodeAttr.ShowLineNumbers]: event.target.checked ? 'true' : '',
83+
}),
84+
);
85+
};
86+
87+
return (
88+
<Checkbox
89+
checked={showLineNumbers}
90+
className={bCodeBlock('show-line-numbers')}
91+
content={i18n('show_line_numbers')}
92+
onChange={handleChange}
93+
/>
94+
);
95+
};
96+
5997
export const codeLangSelectTooltipViewCreator = (
6098
view: EditorView,
6199
langItems: SelectOption[],
62100
mapping: Record<string, string> = {},
101+
showLineNumbers: boolean,
63102
) => {
64103
return new BaseTooltipPluginView(view, {
65104
idPrefix: 'code-block-tooltip',
66105
nodeType: codeBlockType(view.state.schema),
67106
popupPlacement: ['bottom', 'top'],
68-
content: (view, {node, pos}) => (
69-
<Toolbar
70-
editor={{}}
71-
focus={() => view.focus()}
72-
className="g-md-code-block-toolbar"
73-
data={[
74-
[
75-
{
76-
id: 'code-block-type',
77-
type: ToolbarDataType.ReactComponent,
78-
component: () => (
79-
<CodeMenu
80-
view={view}
81-
pos={pos}
82-
node={node}
83-
selectItems={langItems}
84-
mapping={mapping}
85-
/>
86-
),
87-
width: 28,
88-
},
89-
],
90-
[
91-
{
92-
id: 'code-block-remove',
93-
icon: {data: TrashBin},
94-
title: i18n('remove'),
95-
type: ToolbarDataType.SingleButton,
96-
isActive: () => false,
97-
isEnable: () => true,
98-
exec: () =>
99-
removeNode({
100-
pos: pos,
101-
node: node,
102-
tr: view.state.tr,
103-
dispatch: view.dispatch.bind(view),
104-
}),
105-
},
106-
],
107-
]}
108-
/>
109-
),
107+
content: (view, {node, pos}) => {
108+
const lineNumbersCheckbox: ToolbarData<{}>[number][number] = {
109+
id: 'code-block-showlinenumbers',
110+
type: ToolbarDataType.ReactComponent,
111+
component: () => <ShowLineNumbers view={view} pos={pos} node={node} />,
112+
width: 28,
113+
};
114+
115+
return (
116+
<Toolbar
117+
editor={{}}
118+
focus={() => view.focus()}
119+
className={bToolbar()}
120+
data={[
121+
[
122+
{
123+
id: 'code-block-type',
124+
type: ToolbarDataType.ReactComponent,
125+
component: () => (
126+
<CodeMenu
127+
view={view}
128+
pos={pos}
129+
node={node}
130+
selectItems={langItems}
131+
mapping={mapping}
132+
/>
133+
),
134+
width: 28,
135+
},
136+
],
137+
...(showLineNumbers ? [[lineNumbersCheckbox]] : []),
138+
[
139+
{
140+
id: 'code-block-remove',
141+
icon: {data: TrashBin},
142+
title: i18n('remove'),
143+
type: ToolbarDataType.SingleButton,
144+
isActive: () => false,
145+
isEnable: () => true,
146+
exec: () =>
147+
removeNode({
148+
pos: pos,
149+
node: node,
150+
tr: view.state.tr,
151+
dispatch: view.dispatch.bind(view),
152+
}),
153+
},
154+
],
155+
]}
156+
/>
157+
);
158+
},
110159
});
111160
};

0 commit comments

Comments
 (0)