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'); + }); });