diff --git a/src/components/ContentNode.vue b/src/components/ContentNode.vue
index 3178b919f..d9d5e2a90 100644
--- a/src/components/ContentNode.vue
+++ b/src/components/ContentNode.vue
@@ -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 });
}
diff --git a/src/components/ContentNode/CodeListing.vue b/src/components/ContentNode/CodeListing.vue
index 24623ead2..66b96cc63 100644
--- a/src/components/ContentNode/CodeListing.vue
+++ b/src/components/ContentNode/CodeListing.vue
@@ -12,7 +12,8 @@
false,
},
+ wrap: {
+ type: Number,
+ default: () => 0,
+ },
+ lineAnnotations: {
+ type: Array,
+ default: () => [],
+ },
startLineNumber: {
type: Number,
default: () => 1,
@@ -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: {
@@ -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;
@@ -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;
@@ -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;
}
diff --git a/tests/unit/components/ContentNode.spec.js b/tests/unit/components/ContentNode.spec.js
index 1fdffa796..80e5fe5a7 100644
--- a/tests/unit/components/ContentNode.spec.js
+++ b/tests/unit/components/ContentNode.spec.js
@@ -102,6 +102,8 @@ describe('ContentNode', () => {
fileType: 'swift',
code: ['foobar'],
copyToClipboard: false,
+ wrap: 0,
+ lineAnnotations: [],
};
it('renders a `CodeListing`', () => {
@@ -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);
});
diff --git a/tests/unit/components/ContentNode/CodeListing.spec.js b/tests/unit/components/ContentNode/CodeListing.spec.js
index afeee43b1..6ae0ad2e9 100644
--- a/tests/unit/components/ContentNode/CodeListing.spec.js
+++ b/tests/unit/components/ContentNode/CodeListing.spec.js
@@ -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', () => {
@@ -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: {
@@ -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');
+ });
});