diff --git a/src/components/ContentNode.vue b/src/components/ContentNode.vue index ff8fd5175..3178b919f 100644 --- a/src/components/ContentNode.vue +++ b/src/components/ContentNode.vue @@ -274,6 +274,7 @@ function renderNode(createElement, references) { fileType: node.fileType, content: node.code, showLineNumbers: node.showLineNumbers, + copyToClipboard: node.copyToClipboard ?? false, }; return createElement(CodeListing, { props }); } diff --git a/src/components/ContentNode/CodeListing.vue b/src/components/ContentNode/CodeListing.vue index 8f1524fc8..3efa35bef 100644 --- a/src/components/ContentNode/CodeListing.vue +++ b/src/components/ContentNode/CodeListing.vue @@ -1,7 +1,7 @@
false, + }, startLineNumber: { type: Number, default: () => 1, @@ -92,6 +115,9 @@ export default { const fallbackMap = { occ: Language.objectiveC.key.url }; return fallbackMap[this.syntax] || this.syntax; }, + copyableText() { + return this.content.join('\n'); + }, }, watch: { content: { @@ -122,6 +148,18 @@ export default { line === '' ? '\n' : line )); }, + copyCodeToClipboard() { + navigator.clipboard.writeText(this.copyableText) + .then(() => { + this.isCopied = true; + setTimeout(() => { + this.isCopied = false; + }, 1000); + }) + .catch(err => ( + console.error('Failed to copy text: ', err) + )); + }, }, }; @@ -187,6 +225,7 @@ code { flex-direction: column; border-radius: var(--code-border-radius, $border-radius); overflow: hidden; + position: relative; // we need to establish a new stacking context to resolve a Safari bug where // the scrollbar is not clipped by this element depending on its border-radius @include new-stacking-context; @@ -205,4 +244,49 @@ pre { flex-grow: 1; } +.copy-button { + position: absolute; + top: 0.2em; + right: 0.2em; + width: 1.5em; + height: 1.5em; + background: var(--color-fill-gray-tertiary); + border: none; + border-radius: var(--button-border-radius, $button-radius); + padding: 4px; +} + +@media (hover: hover) { + .copy-button { + opacity: 0; + transition: all 0.2s ease-in-out; + } + + .copy-button:hover { + background-color: var(--color-fill-gray); + } + + .copy-button .copy-icon { + opacity: 0.8; + } + + .copy-button:hover .copy-icon { + opacity: 1; + } + + .container-general:hover .copy-button { + opacity: 1; + } +} + +@media (hover: none) { + .copy-button { + opacity: 1; + } +} + +.copy-button.copied .checkmark-icon { + color: var(--color-figure-blue); +} + diff --git a/src/components/Icons/CheckmarkIcon.vue b/src/components/Icons/CheckmarkIcon.vue new file mode 100644 index 000000000..cbdb365d9 --- /dev/null +++ b/src/components/Icons/CheckmarkIcon.vue @@ -0,0 +1,32 @@ + + + + + + + + + diff --git a/src/components/Icons/CopyIcon.vue b/src/components/Icons/CopyIcon.vue new file mode 100644 index 000000000..1c28d4ec2 --- /dev/null +++ b/src/components/Icons/CopyIcon.vue @@ -0,0 +1,34 @@ + + + + + + + + + diff --git a/src/lang/locales/en-US.json b/src/lang/locales/en-US.json index 681923433..04f995b89 100644 --- a/src/lang/locales/en-US.json +++ b/src/lang/locales/en-US.json @@ -207,7 +207,8 @@ "icons": { "clear": "Clear", "web-service-endpoint": "Web Service Endpoint", - "search": "Search" + "search": "Search", + "copy": "Copy code to clipboard" }, "formats": { "parenthesis": "({content})", diff --git a/tests/unit/components/ContentNode.spec.js b/tests/unit/components/ContentNode.spec.js index b028ac0ea..80b7837ab 100644 --- a/tests/unit/components/ContentNode.spec.js +++ b/tests/unit/components/ContentNode.spec.js @@ -101,6 +101,7 @@ describe('ContentNode', () => { syntax: 'swift', fileType: 'swift', code: ['foobar'], + copyToClipboard: false, }; it('renders a `CodeListing`', () => { @@ -111,6 +112,7 @@ describe('ContentNode', () => { expect(codeListing.props('syntax')).toBe(listing.syntax); expect(codeListing.props('fileType')).toBe(listing.fileType); expect(codeListing.props('content')).toEqual(listing.code); + expect(codeListing.props('copyToClipboard')).toEqual(listing.copyToClipboard); expect(codeListing.isEmpty()).toBe(true); }); @@ -138,6 +140,29 @@ describe('ContentNode', () => { }); }); + describe('with type="codeListing" and copy set', () => { + const listing = { + type: 'codeListing', + syntax: 'swift', + fileType: 'swift', + code: ['foobar'], + copyToClipboard: true, + }; + + // renders a copy button + it('renders a copy button', () => { + const wrapper = mountWithItem(listing); + + const codeListing = wrapper.find('.content').find(CodeListing); + expect(codeListing.exists()).toBe(true); + expect(codeListing.props('syntax')).toBe(listing.syntax); + expect(codeListing.props('fileType')).toBe(listing.fileType); + expect(codeListing.props('content')).toEqual(listing.code); + expect(codeListing.props('copyToClipboard')).toEqual(listing.copyToClipboard); + expect(codeListing.isEmpty()).toBe(true); + }); + }); + describe('with type="endpointExample"', () => { it('renders an `EndpointExample`', () => { const request = { diff --git a/tests/unit/components/ContentNode/CodeListing.spec.js b/tests/unit/components/ContentNode/CodeListing.spec.js index 127b8c33d..2d282b0df 100644 --- a/tests/unit/components/ContentNode/CodeListing.spec.js +++ b/tests/unit/components/ContentNode/CodeListing.spec.js @@ -139,6 +139,32 @@ describe('CodeListing', () => { expect(wrapper.html().includes('.syntax')).toBe(false); }); + it('does not show copy button when its disabled', async () => { + const wrapper = shallowMount(CodeListing, { + propsData: { + syntax: 'swift', + content: ['let foo = "bar"'], + copyToClipboard: false, + }, + }); + await flushPromises(); + + expect(wrapper.find('.copy-button').exists()).toBe(false); + }); + + it('shows copy button when its enabled', async () => { + const wrapper = shallowMount(CodeListing, { + propsData: { + syntax: 'swift', + content: ['let foo = "bar"'], + copyToClipboard: true, + }, + }); + await flushPromises(); + + expect(wrapper.find('.copy-button').exists()).toBe(true); + }); + it('renders code with empty spaces', async () => { const wrapper = shallowMount(CodeListing, { propsData: {