Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/components/ContentNode.vue
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,8 @@ function renderNode(createElement, references) {
content: node.code,
showLineNumbers: node.showLineNumbers,
copyToClipboard: node.copyToClipboard ?? false,
wrap: node.wrap ?? 0,
lineAnnotations: node.lineAnnotations ?? [],
};
return createElement(CodeListing, { props });
}
Expand Down
86 changes: 83 additions & 3 deletions src/components/ContentNode/CodeListing.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
<div
class="code-listing"
:data-syntax="syntaxNameNormalized"
:class="{ 'single-line': syntaxHighlightedLines.length === 1 }"
:class="{ 'single-line': syntaxHighlightedLines.length === 1, 'is-wrapped': wrap > 0 }"
:style="wrap > 0 ? { '--wrap-ch': wrap } : null"
>
<Filename
v-if="fileName"
Expand Down Expand Up @@ -40,7 +41,13 @@
v-for="(line, index) in syntaxHighlightedLines"
><span
:key="index"
:class="['code-line-container',{ highlighted: isHighlighted(index) }]"
:class="[
'code-line-container',
{
highlighted: isHighlighted(index) || isUserHighlighted(index),
strikethrough: isUserStrikethrough(index),
}
]"
><span
v-if="showLineNumbers"
class="code-number"
Expand Down Expand Up @@ -71,6 +78,11 @@ const CopyState = {
failure: 'failure',
};

export const LineStyle = {
highlight: 'highlight',
strikeout: 'strikeout',
};

export default {
name: 'CodeListing',
components: {
Expand Down Expand Up @@ -103,6 +115,14 @@ export default {
type: Boolean,
default: () => false,
},
wrap: {
type: Number,
default: () => 0,
},
lineAnnotations: {
type: Array,
default: () => [],
},
startLineNumber: {
type: Number,
default: () => 1,
Expand All @@ -129,6 +149,30 @@ export default {
copyableText() {
return this.content.join('\n');
},
styleLineSets() {
const sets = Object.create(null);

(this.lineAnnotations || []).forEach((a) => {
if (!a || !a.style || !a.range || !a.range[0] || !a.range[1]) {
return;
}

const { style } = a;
const startLine = a.range[0].line;
const endLine = a.range[1].line;

if (!sets[style]) {
sets[style] = new Set();
}

// add all lines within the range to check membership
for (let line = startLine; line <= endLine; line += 1) {
sets[style].add(line);
}
});

return sets;
},
},
watch: {
content: {
Expand All @@ -140,6 +184,16 @@ export default {
isHighlighted(index) {
return this.highlightedLineNumbers.has(this.lineNumberFor(index));
},
isLineInStyle(index, style) {
const lineNumber = this.lineNumberFor(index);
return this.styleLineSets[style]?.has(lineNumber) ?? false;
},
isUserHighlighted(index) {
return this.isLineInStyle(index, LineStyle.highlight);
},
isUserStrikethrough(index) {
return this.isLineInStyle(index, LineStyle.strikeout);
},
// Returns the line number for the line at the given index in `content`.
lineNumberFor(index) {
return this.startLineNumber + index;
Expand Down Expand Up @@ -200,15 +254,30 @@ export default {
}
}

.code-listing:not(:has(.code-number)):has(.highlighted) .code-line-container {
padding-left: $code-number-padding-left;
border-left: $highlighted-border-width solid transparent;
}

.code-listing:not(:has(.code-number)):has(.highlighted) .highlighted {
border-left-color: var(--color-code-line-highlight-border);
}

.highlighted {
background: var(--line-highlight, var(--color-code-line-highlight));
border-left: $highlighted-border-width solid var(--color-code-line-highlight-border);

.code-number {
border-left: $highlighted-border-width solid var(--color-code-line-highlight-border);
padding-left: $code-number-padding-left - $highlighted-border-width;
}
}

.strikethrough {
text-decoration-line: line-through;
text-decoration-color: var(--color-figure-gray);
opacity: 0.85;
}

pre {
padding: $code-listing-with-numbers-padding;
display: flex;
Expand Down Expand Up @@ -249,6 +318,17 @@ code {
}
}

.is-wrapped pre,
.is-wrapped code {
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: normal;
}

.is-wrapped pre {
max-width: calc(var(--wrap-ch) * 1ch);
}

.container-general {
overflow: auto;
}
Expand Down
4 changes: 4 additions & 0 deletions tests/unit/components/ContentNode.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ describe('ContentNode', () => {
fileType: 'swift',
code: ['foobar'],
copyToClipboard: false,
wrap: 0,
lineAnnotations: [],
};

it('renders a `CodeListing`', () => {
Expand All @@ -113,6 +115,8 @@ describe('ContentNode', () => {
expect(codeListing.props('fileType')).toBe(listing.fileType);
expect(codeListing.props('content')).toEqual(listing.code);
expect(codeListing.props('copyToClipboard')).toEqual(listing.copyToClipboard);
expect(codeListing.props('wrap')).toEqual(listing.wrap);
expect(codeListing.props('lineAnnotations')).toEqual(listing.lineAnnotations);
expect(codeListing.element.childElementCount === 0).toBe(true);
});

Expand Down
116 changes: 115 additions & 1 deletion tests/unit/components/ContentNode/CodeListing.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
*/

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

describe('CodeListing', () => {
Expand Down Expand Up @@ -98,6 +98,55 @@ describe('CodeListing', () => {
});
});

it('styles the correct lines from lineAnnotations with the specified style', async () => {
const content = ['a', 'b', 'c', 'd', 'e'];
const lineAnnotations = [
{ style: LineStyle.highlight, range: [{ line: 1 }, { line: 1 }] },
{ style: LineStyle.highlight, range: [{ line: 2 }, { line: 2 }] },
{ style: LineStyle.strikeout, range: [{ line: 1 }, { line: 1 }] },
{ style: LineStyle.strikeout, range: [{ line: 3 }, { line: 3 }] },
];

const wrapper = shallowMount(CodeListing, {
propsData: {
content,
lineAnnotations,
showLineNumbers: true,
},
});

await flushPromises();

const pre = wrapper.find('pre');
expect(pre.exists()).toBe(true);

const codeLineContainers = wrapper.findAll('span.code-line-container');
expect(codeLineContainers.length).toBe(content.length);

const highlightedLines = lineAnnotations
.filter(a => a.style === 'highlight')
.flatMap(a => a.range.map(r => r.line));
const strikethroughLines = lineAnnotations
.filter(a => a.style === 'strikeout')
.flatMap(a => a.range.map(r => r.line));

content.forEach((line, index) => {
const codeLineContainer = codeLineContainers.at(index);
const shouldBeHighlighted = highlightedLines.includes(index + 1);
const shouldBeStriked = strikethroughLines.includes(index + 1);

const codeNumber = codeLineContainer.find('.code-number');

expect(codeNumber.attributes('data-line-number')).toBe(`${index + 1}`);

const codeLine = codeLineContainer.find('.code-line');
expect(codeLine.text()).toBe(line);

expect(codeLineContainer.classes('highlighted')).toBe(shouldBeHighlighted);
expect(codeLineContainer.classes('strikethrough')).toBe(shouldBeStriked);
});
});

it('syntax highlights code for Swift', async () => {
const wrapper = shallowMount(CodeListing, {
propsData: {
Expand Down Expand Up @@ -232,4 +281,69 @@ describe('CodeListing', () => {

expect(wrapper.classes()).toContain('single-line');
});

it('does not wrap when wrap=0', async () => {
const wrapper = shallowMount(CodeListing, {
propsData: {
syntax: 'swift',
content: ['let foo = "bar"'],
wrap: 0,
},
});
await flushPromises();

expect(wrapper.classes()).not.toContain('is-wrapped');

const style = wrapper.attributes('style') || '';
expect(style).not.toMatch(/--wrap-ch:\s*\d+/);
});

it('wraps when wrap>0 and exposes the width in style', async () => {
const wrapper = shallowMount(CodeListing, {
propsData: {
syntax: 'swift',
content: ['let foo = "bar"'],
wrap: 80,
},
});
await flushPromises();

expect(wrapper.classes()).toContain('is-wrapped');

const style = wrapper.attributes('style') || '';
expect(style).toMatch(/--wrap-ch:\s*80\b/);
});

it('reacts when wrap changes', async () => {
const wrapper = shallowMount(CodeListing, {
propsData: {
syntax: 'swift',
content: ['let foo = "bar"'],
wrap: 80,
},
});
await flushPromises();

expect(wrapper.classes()).toContain('is-wrapped');

let style = wrapper.attributes('style') || '';
expect(style).toMatch(/--wrap-ch:\s*80\b/);

await wrapper.setProps({ wrap: 0 });
style = wrapper.attributes('style') || '';
expect(style === null || style === '').toBe(true);
});

it('treats negative wrap as no-wrap', async () => {
const wrapper = shallowMount(CodeListing, {
propsData: {
syntax: 'swift',
content: ['let foo = "bar"'],
wrap: -5,
},
});
await flushPromises();

expect(wrapper.classes()).not.toContain('is-wrapped');
});
});