Skip to content

Commit b3b1f45

Browse files
authored
Implement highlight/strikeout/wrap for code listings (#965)
Add support for wrapping, highlighting, and striking out lines in code listings
1 parent a2ac150 commit b3b1f45

File tree

4 files changed

+204
-4
lines changed

4 files changed

+204
-4
lines changed

src/components/ContentNode.vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,8 @@ function renderNode(createElement, references) {
275275
content: node.code,
276276
showLineNumbers: node.showLineNumbers,
277277
copyToClipboard: node.copyToClipboard ?? false,
278+
wrap: node.wrap ?? 0,
279+
lineAnnotations: node.lineAnnotations ?? [],
278280
};
279281
return createElement(CodeListing, { props });
280282
}

src/components/ContentNode/CodeListing.vue

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
<div
1313
class="code-listing"
1414
:data-syntax="syntaxNameNormalized"
15-
:class="{ 'single-line': syntaxHighlightedLines.length === 1 }"
15+
:class="{ 'single-line': syntaxHighlightedLines.length === 1, 'is-wrapped': wrap > 0 }"
16+
:style="wrap > 0 ? { '--wrap-ch': wrap } : null"
1617
>
1718
<Filename
1819
v-if="fileName"
@@ -40,7 +41,13 @@
4041
v-for="(line, index) in syntaxHighlightedLines"
4142
><span
4243
:key="index"
43-
:class="['code-line-container',{ highlighted: isHighlighted(index) }]"
44+
:class="[
45+
'code-line-container',
46+
{
47+
highlighted: isHighlighted(index) || isUserHighlighted(index),
48+
strikethrough: isUserStrikethrough(index),
49+
}
50+
]"
4451
><span
4552
v-if="showLineNumbers"
4653
class="code-number"
@@ -71,6 +78,11 @@ const CopyState = {
7178
failure: 'failure',
7279
};
7380
81+
export const LineStyle = {
82+
highlight: 'highlight',
83+
strikeout: 'strikeout',
84+
};
85+
7486
export default {
7587
name: 'CodeListing',
7688
components: {
@@ -103,6 +115,14 @@ export default {
103115
type: Boolean,
104116
default: () => false,
105117
},
118+
wrap: {
119+
type: Number,
120+
default: () => 0,
121+
},
122+
lineAnnotations: {
123+
type: Array,
124+
default: () => [],
125+
},
106126
startLineNumber: {
107127
type: Number,
108128
default: () => 1,
@@ -129,6 +149,30 @@ export default {
129149
copyableText() {
130150
return this.content.join('\n');
131151
},
152+
styleLineSets() {
153+
const sets = Object.create(null);
154+
155+
(this.lineAnnotations || []).forEach((a) => {
156+
if (!a || !a.style || !a.range || !a.range[0] || !a.range[1]) {
157+
return;
158+
}
159+
160+
const { style } = a;
161+
const startLine = a.range[0].line;
162+
const endLine = a.range[1].line;
163+
164+
if (!sets[style]) {
165+
sets[style] = new Set();
166+
}
167+
168+
// add all lines within the range to check membership
169+
for (let line = startLine; line <= endLine; line += 1) {
170+
sets[style].add(line);
171+
}
172+
});
173+
174+
return sets;
175+
},
132176
},
133177
watch: {
134178
content: {
@@ -140,6 +184,16 @@ export default {
140184
isHighlighted(index) {
141185
return this.highlightedLineNumbers.has(this.lineNumberFor(index));
142186
},
187+
isLineInStyle(index, style) {
188+
const lineNumber = this.lineNumberFor(index);
189+
return this.styleLineSets[style]?.has(lineNumber) ?? false;
190+
},
191+
isUserHighlighted(index) {
192+
return this.isLineInStyle(index, LineStyle.highlight);
193+
},
194+
isUserStrikethrough(index) {
195+
return this.isLineInStyle(index, LineStyle.strikeout);
196+
},
143197
// Returns the line number for the line at the given index in `content`.
144198
lineNumberFor(index) {
145199
return this.startLineNumber + index;
@@ -200,15 +254,30 @@ export default {
200254
}
201255
}
202256
257+
.code-listing:not(:has(.code-number)):has(.highlighted) .code-line-container {
258+
padding-left: $code-number-padding-left;
259+
border-left: $highlighted-border-width solid transparent;
260+
}
261+
262+
.code-listing:not(:has(.code-number)):has(.highlighted) .highlighted {
263+
border-left-color: var(--color-code-line-highlight-border);
264+
}
265+
203266
.highlighted {
204267
background: var(--line-highlight, var(--color-code-line-highlight));
205-
border-left: $highlighted-border-width solid var(--color-code-line-highlight-border);
206268
207269
.code-number {
270+
border-left: $highlighted-border-width solid var(--color-code-line-highlight-border);
208271
padding-left: $code-number-padding-left - $highlighted-border-width;
209272
}
210273
}
211274
275+
.strikethrough {
276+
text-decoration-line: line-through;
277+
text-decoration-color: var(--color-figure-gray);
278+
opacity: 0.85;
279+
}
280+
212281
pre {
213282
padding: $code-listing-with-numbers-padding;
214283
display: flex;
@@ -249,6 +318,17 @@ code {
249318
}
250319
}
251320
321+
.is-wrapped pre,
322+
.is-wrapped code {
323+
white-space: pre-wrap;
324+
overflow-wrap: anywhere;
325+
word-break: normal;
326+
}
327+
328+
.is-wrapped pre {
329+
max-width: calc(var(--wrap-ch) * 1ch);
330+
}
331+
252332
.container-general {
253333
overflow: auto;
254334
}

tests/unit/components/ContentNode.spec.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@ describe('ContentNode', () => {
102102
fileType: 'swift',
103103
code: ['foobar'],
104104
copyToClipboard: false,
105+
wrap: 0,
106+
lineAnnotations: [],
105107
};
106108

107109
it('renders a `CodeListing`', () => {
@@ -113,6 +115,8 @@ describe('ContentNode', () => {
113115
expect(codeListing.props('fileType')).toBe(listing.fileType);
114116
expect(codeListing.props('content')).toEqual(listing.code);
115117
expect(codeListing.props('copyToClipboard')).toEqual(listing.copyToClipboard);
118+
expect(codeListing.props('wrap')).toEqual(listing.wrap);
119+
expect(codeListing.props('lineAnnotations')).toEqual(listing.lineAnnotations);
116120
expect(codeListing.element.childElementCount === 0).toBe(true);
117121
});
118122

tests/unit/components/ContentNode/CodeListing.spec.js

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
*/
1010

1111
import { shallowMount } from '@vue/test-utils';
12-
import CodeListing from 'docc-render/components/ContentNode/CodeListing.vue';
12+
import CodeListing, { LineStyle } from 'docc-render/components/ContentNode/CodeListing.vue';
1313
import { flushPromises } from '../../../../test-utils';
1414

1515
describe('CodeListing', () => {
@@ -98,6 +98,55 @@ describe('CodeListing', () => {
9898
});
9999
});
100100

101+
it('styles the correct lines from lineAnnotations with the specified style', async () => {
102+
const content = ['a', 'b', 'c', 'd', 'e'];
103+
const lineAnnotations = [
104+
{ style: LineStyle.highlight, range: [{ line: 1 }, { line: 1 }] },
105+
{ style: LineStyle.highlight, range: [{ line: 2 }, { line: 2 }] },
106+
{ style: LineStyle.strikeout, range: [{ line: 1 }, { line: 1 }] },
107+
{ style: LineStyle.strikeout, range: [{ line: 3 }, { line: 3 }] },
108+
];
109+
110+
const wrapper = shallowMount(CodeListing, {
111+
propsData: {
112+
content,
113+
lineAnnotations,
114+
showLineNumbers: true,
115+
},
116+
});
117+
118+
await flushPromises();
119+
120+
const pre = wrapper.find('pre');
121+
expect(pre.exists()).toBe(true);
122+
123+
const codeLineContainers = wrapper.findAll('span.code-line-container');
124+
expect(codeLineContainers.length).toBe(content.length);
125+
126+
const highlightedLines = lineAnnotations
127+
.filter(a => a.style === 'highlight')
128+
.flatMap(a => a.range.map(r => r.line));
129+
const strikethroughLines = lineAnnotations
130+
.filter(a => a.style === 'strikeout')
131+
.flatMap(a => a.range.map(r => r.line));
132+
133+
content.forEach((line, index) => {
134+
const codeLineContainer = codeLineContainers.at(index);
135+
const shouldBeHighlighted = highlightedLines.includes(index + 1);
136+
const shouldBeStriked = strikethroughLines.includes(index + 1);
137+
138+
const codeNumber = codeLineContainer.find('.code-number');
139+
140+
expect(codeNumber.attributes('data-line-number')).toBe(`${index + 1}`);
141+
142+
const codeLine = codeLineContainer.find('.code-line');
143+
expect(codeLine.text()).toBe(line);
144+
145+
expect(codeLineContainer.classes('highlighted')).toBe(shouldBeHighlighted);
146+
expect(codeLineContainer.classes('strikethrough')).toBe(shouldBeStriked);
147+
});
148+
});
149+
101150
it('syntax highlights code for Swift', async () => {
102151
const wrapper = shallowMount(CodeListing, {
103152
propsData: {
@@ -232,4 +281,69 @@ describe('CodeListing', () => {
232281

233282
expect(wrapper.classes()).toContain('single-line');
234283
});
284+
285+
it('does not wrap when wrap=0', async () => {
286+
const wrapper = shallowMount(CodeListing, {
287+
propsData: {
288+
syntax: 'swift',
289+
content: ['let foo = "bar"'],
290+
wrap: 0,
291+
},
292+
});
293+
await flushPromises();
294+
295+
expect(wrapper.classes()).not.toContain('is-wrapped');
296+
297+
const style = wrapper.attributes('style') || '';
298+
expect(style).not.toMatch(/--wrap-ch:\s*\d+/);
299+
});
300+
301+
it('wraps when wrap>0 and exposes the width in style', async () => {
302+
const wrapper = shallowMount(CodeListing, {
303+
propsData: {
304+
syntax: 'swift',
305+
content: ['let foo = "bar"'],
306+
wrap: 80,
307+
},
308+
});
309+
await flushPromises();
310+
311+
expect(wrapper.classes()).toContain('is-wrapped');
312+
313+
const style = wrapper.attributes('style') || '';
314+
expect(style).toMatch(/--wrap-ch:\s*80\b/);
315+
});
316+
317+
it('reacts when wrap changes', async () => {
318+
const wrapper = shallowMount(CodeListing, {
319+
propsData: {
320+
syntax: 'swift',
321+
content: ['let foo = "bar"'],
322+
wrap: 80,
323+
},
324+
});
325+
await flushPromises();
326+
327+
expect(wrapper.classes()).toContain('is-wrapped');
328+
329+
let style = wrapper.attributes('style') || '';
330+
expect(style).toMatch(/--wrap-ch:\s*80\b/);
331+
332+
await wrapper.setProps({ wrap: 0 });
333+
style = wrapper.attributes('style') || '';
334+
expect(style === null || style === '').toBe(true);
335+
});
336+
337+
it('treats negative wrap as no-wrap', async () => {
338+
const wrapper = shallowMount(CodeListing, {
339+
propsData: {
340+
syntax: 'swift',
341+
content: ['let foo = "bar"'],
342+
wrap: -5,
343+
},
344+
});
345+
await flushPromises();
346+
347+
expect(wrapper.classes()).not.toContain('is-wrapped');
348+
});
235349
});

0 commit comments

Comments
 (0)