Skip to content

Commit b9599f9

Browse files
authored
fix(code-viewer): get rid of trailing lines (#188)
1 parent 0251577 commit b9599f9

7 files changed

Lines changed: 136 additions & 81 deletions

File tree

setupTests.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,14 @@ Enzyme.configure({ adapter: new Adapter() });
55
jest.mock('lodash');
66
jest.mock('lodash/debounce');
77
jest.mock('react-window');
8+
9+
window.IntersectionObserver = class implements IntersectionObserver {
10+
readonly root!: Element | null;
11+
readonly rootMargin!: string;
12+
readonly thresholds!: ReadonlyArray<number>;
13+
14+
public readonly observe = jest.fn();
15+
public readonly unobserve = jest.fn();
16+
public readonly disconnect = jest.fn();
17+
public readonly takeRecords = jest.fn();
18+
};

src/CodeViewer/__tests__/Viewer.spec.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,15 @@ jest.mock('../components/BlockCodeViewer', () => ({
1414
BlockCodeViewer: jest.requireActual('../components/BlockCodeViewer/BlockCodeViewer').default,
1515
}));
1616

17+
jest.mock('../components/BlockCodeViewer/ObservableSet', () => ({
18+
ObservableSet: class extends Set {
19+
public readonly addListener = jest.fn().mockImplementation((node, listener) => {
20+
listener();
21+
return jest.fn();
22+
});
23+
},
24+
}));
25+
1726
jest.mock('use-resize-observer', () => ({
1827
default: jest.fn(),
1928
}));

src/CodeViewer/components/BlockCodeViewer/BlockCodeViewer.tsx

Lines changed: 49 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ function calculateMaxLines(height: number) {
2222
const BlockCodeViewer: React.FC<IBlockCodeViewerProps> = ({ className, language, value, showLineNumbers, ...rest }) => {
2323
const nodeRef = React.useRef<HTMLPreElement | null>(null);
2424
const [maxLines, setMaxLines] = React.useState<number | null>(null);
25-
const observerRef = React.useRef(new ObservableSet());
25+
const [observer, setObserver] = React.useState<IntersectionObserver>();
26+
const viewportSet = React.useRef(new ObservableSet());
2627
const slicedBlocks = useSlicedBlocks(value, maxLines === null ? null : Math.max(0, maxLines - 1));
2728
const lineNumberCharacterCount = String(
2829
slicedBlocks !== null && maxLines !== null ? slicedBlocks.length * maxLines : 0,
@@ -31,67 +32,60 @@ const BlockCodeViewer: React.FC<IBlockCodeViewerProps> = ({ className, language,
3132
React.useLayoutEffect(() => {
3233
if (nodeRef.current !== null) {
3334
setMaxLines(calculateMaxLines(window.innerHeight)); // we have to use window here, as element may not ave any height at this time
34-
highlightRelevantParts(nodeRef.current);
3535
}
36-
// eslint-disable-next-line react-hooks/exhaustive-deps
3736
}, [nodeRef]);
3837

39-
useResizeObserver({
40-
onResize: debounce(({ height }) => {
41-
const newMaxLines = calculateMaxLines(height);
42-
if (newMaxLines !== maxLines) {
43-
setMaxLines(newMaxLines);
44-
}
45-
}, 250),
46-
ref: nodeRef,
47-
});
48-
49-
function highlightRelevantParts(target: EventTarget) {
50-
if (slicedBlocks === null || maxLines === null) return;
51-
52-
const value =
53-
(target === nodeRef.current ? nodeRef.current.scrollTop : window.pageYOffset) /
54-
(SINGLE_LINE_SIZE * maxLines - SINGLE_LINE_SIZE);
55-
const blockNo = Math.round(value);
56-
57-
// see https://github.com/stoplightio/ui-kit/pull/180 for the reasoning
58-
observerRef.current.add(blockNo);
59-
observerRef.current.add(Math.min(slicedBlocks.length - 1, blockNo + 1));
60-
observerRef.current.add(Math.max(0, blockNo - 1));
61-
62-
if (value > blockNo) {
63-
observerRef.current.add(Math.min(slicedBlocks.length - 1, blockNo + 2));
64-
} else {
65-
observerRef.current.add(Math.max(0, blockNo - 2));
66-
}
67-
}
68-
6938
React.useEffect(() => {
70-
observerRef.current.clear();
71-
}, [maxLines]);
39+
const { current: viewport } = viewportSet;
40+
if (nodeRef.current === null || maxLines === null) {
41+
return;
42+
}
7243

73-
React.useEffect(() => {
74-
const { current: root } = nodeRef;
44+
const observer = new IntersectionObserver(
45+
entries => {
46+
for (const entry of entries) {
47+
if (!entry.isIntersecting) continue;
7548

76-
if (root === null || maxLines === null) return;
49+
viewport.add(entry.target);
50+
const { previousElementSibling, nextElementSibling } = entry.target;
7751

78-
const handler: EventListener = debounce(e => {
79-
if (e.target !== null) {
80-
highlightRelevantParts(e.target);
81-
}
82-
}, 32);
52+
// highlight siblings to reduce flickering while scrolling using page up/down
53+
if (previousElementSibling?.tagName === 'DIV') {
54+
viewport.add(previousElementSibling);
55+
}
8356

84-
highlightRelevantParts(root.offsetHeight > window.innerHeight ? window : root);
57+
if (nextElementSibling?.tagName === 'DIV') {
58+
viewport.add(nextElementSibling);
59+
}
60+
}
61+
},
62+
{
63+
root: null,
64+
threshold: 0,
65+
},
66+
);
8567

86-
window.addEventListener('scroll', handler, { passive: true });
87-
root.addEventListener('scroll', handler, { passive: true });
68+
setObserver(observer);
8869

8970
return () => {
90-
root.removeEventListener('scroll', handler);
91-
window.removeEventListener('scroll', handler);
71+
setObserver(void 0);
72+
observer.disconnect();
9273
};
93-
// eslint-disable-next-line react-hooks/exhaustive-deps
94-
}, [observerRef, nodeRef, maxLines]);
74+
}, [nodeRef, maxLines]);
75+
76+
useResizeObserver({
77+
onResize: debounce(
78+
({ height }) => {
79+
const newMaxLines = calculateMaxLines(height);
80+
if (newMaxLines !== maxLines) {
81+
setMaxLines(newMaxLines);
82+
}
83+
},
84+
250,
85+
{ leading: true },
86+
),
87+
ref: nodeRef,
88+
});
9589

9690
return (
9791
<pre
@@ -101,15 +95,15 @@ const BlockCodeViewer: React.FC<IBlockCodeViewerProps> = ({ className, language,
10195
})}
10296
{...rest}
10397
>
104-
{slicedBlocks?.map((value, index) => (
98+
{slicedBlocks?.map(({ id, value }, index, blocks) => (
10599
<SingleCodeBlock
106-
key={index}
100+
key={id}
107101
value={value}
108102
language={language}
109103
showLineNumbers={showLineNumbers}
110-
index={index}
111-
lineNumber={maxLines === null ? 0 : index * maxLines + 1}
112-
observer={observerRef.current}
104+
lineNumber={(index > 0 ? blocks[index - 1].lineCount : 0) + 1}
105+
observer={observer}
106+
viewport={viewportSet.current}
113107
/>
114108
))}
115109
</pre>

src/CodeViewer/components/BlockCodeViewer/ObservableSet.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
1-
export class ObservableSet extends Set<number> {
2-
private readonly listeners = new Map<number, Function>();
1+
export class ObservableSet extends WeakSet<Element> {
2+
private readonly listeners = new WeakMap<Element, Function>();
33

4-
addListener(item: number, cb: Function) {
4+
addListener(item: Element, cb: Function) {
55
this.listeners.set(item, cb);
66

77
return () => {
88
this.listeners.delete(item);
99
};
1010
}
1111

12-
add(item: number) {
12+
add(item: Element) {
1313
if (super.has(item)) return this;
1414

1515
super.add(item);
Lines changed: 43 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as React from 'react';
2+
import type { RefractorNode } from 'refractor/core';
23

34
import { astToReact } from '../../utils/astToReact';
45
import { lineNumberify } from '../../utils/lineNumberify';
@@ -10,44 +11,69 @@ interface IBlockProps {
1011
language: string | undefined;
1112
showLineNumbers: boolean;
1213
lineNumber: number;
13-
index: number;
14-
observer: ObservableSet;
14+
observer: IntersectionObserver | undefined;
15+
viewport: ObservableSet;
16+
}
17+
18+
const WHITESPACE_REGEX = /^[\s\n]+$/;
19+
20+
function isWhitespace(str: string) {
21+
return WHITESPACE_REGEX.test(str);
22+
}
23+
24+
function isTrailingWhiteLine(node: RefractorNode) {
25+
if (node.type === 'text') {
26+
return isWhitespace(node.value);
27+
}
28+
29+
if ('children' in node && node.children.length === 1 && 'value' in node.children[0]) {
30+
return isWhitespace(node.children[0].value);
31+
}
32+
33+
return false;
1534
}
1635

1736
export const SingleCodeBlock: React.FC<IBlockProps> = ({
1837
value,
1938
language,
2039
showLineNumbers,
21-
index,
2240
lineNumber,
2341
observer,
42+
viewport,
2443
}) => {
2544
const [markup, setMarkup] = React.useState<React.ReactNode[]>();
2645
const [isVisible, setIsVisible] = React.useState(false);
46+
const nodeRef = React.useRef<HTMLDivElement | null>(null);
2747

2848
React.useEffect(() => {
29-
return observer.addListener(index, () => {
49+
const { current: node } = nodeRef;
50+
51+
if (node === null || observer === void 0) {
52+
return;
53+
}
54+
55+
observer.observe(node);
56+
57+
const removeListener = viewport.addListener(node, () => {
3058
setIsVisible(true);
59+
observer.unobserve(node);
3160
});
32-
}, [observer, index, setIsVisible]);
61+
62+
return () => {
63+
observer.unobserve(node);
64+
removeListener();
65+
};
66+
}, [viewport, observer, nodeRef]);
3367

3468
React.useEffect(() => {
3569
if (isVisible) {
3670
try {
3771
const tree = parseCode(value, language);
3872
const processedTree = showLineNumbers ? lineNumberify(tree, lineNumber - 1) : tree;
3973

40-
if (tree.length > 0) {
41-
const lastTreeNode = tree[tree.length - 1];
42-
if (
43-
'children' in lastTreeNode &&
44-
lastTreeNode.children.length === 1 &&
45-
'value' in lastTreeNode.children[0] &&
46-
lastTreeNode.children[0].value === '\n'
47-
) {
48-
// this is to get rid of trailing new lines
49-
processedTree.pop();
50-
}
74+
if (showLineNumbers && tree.length > 0 && isTrailingWhiteLine(tree[tree.length - 1])) {
75+
// this is to get rid of trailing new lines
76+
processedTree.pop();
5177
}
5278

5379
setMarkup(processedTree.map(astToReact(0)));
@@ -61,5 +87,5 @@ export const SingleCodeBlock: React.FC<IBlockProps> = ({
6187
return markup as any;
6288
}
6389

64-
return value;
90+
return <div ref={nodeRef}>{value}</div>;
6591
};

src/CodeViewer/components/BlockCodeViewer/hooks/useSlicedBlocks.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,37 @@
11
import * as React from 'react';
22

3+
type SlicedBlock = {
4+
id: string;
5+
value: string;
6+
lineCount: number;
7+
};
8+
9+
function createSlicedBlock(): SlicedBlock {
10+
return {
11+
id: Math.random().toString(36),
12+
value: '',
13+
lineCount: 0,
14+
};
15+
}
16+
317
export const useSlicedBlocks = (value: string, maxLines: number | null) => {
4-
return React.useMemo<string[] | null>(() => {
18+
return React.useMemo<SlicedBlock[] | null>(() => {
519
if (maxLines === null) {
620
return null;
721
}
822

9-
const blocks: string[] = [''];
23+
const blocks: SlicedBlock[] = [createSlicedBlock()];
1024

1125
for (let i = 0, n = 0; i < value.length; i++) {
1226
const char = value[i];
13-
blocks[blocks.length - 1] += char;
27+
blocks[blocks.length - 1].value += char;
1428

1529
if (char === '\n') {
1630
n++;
1731

1832
if (n % maxLines === 0 && i + 1 !== value.length) {
19-
blocks.push('');
33+
blocks[blocks.length - 1].lineCount = n;
34+
blocks.push(createSlicedBlock());
2035
}
2136
}
2237
}

src/styles/components/Code/_base.scss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
@import 'theme-dark';
33
@import '../../blueprint/colors.scss';
44

5-
$line-number-char-width: 15px;
5+
$line-number-char-width: 10px;
66
$line-padding-left: 10px;
77

88
.CodeEditor,

0 commit comments

Comments
 (0)