Skip to content

Add copy-to-clipboard support to code blocks #961

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
Open
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
1 change: 1 addition & 0 deletions src/components/ContentNode.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
Expand Down
88 changes: 86 additions & 2 deletions src/components/ContentNode/CodeListing.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<!--
This source file is part of the Swift.org open source project

Copyright (c) 2021-2023 Apple Inc. and the Swift project authors
Copyright (c) 2021-2025 Apple Inc. and the Swift project authors
Licensed under Apache License v2.0 with Runtime Library Exception

See https://swift.org/LICENSE.txt for license information
Expand All @@ -22,6 +22,17 @@
>{{ fileName }}
</Filename>
<div class="container-general">
<button
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Design-wise, I think the button can be a little distracting for code listings with a long first-line of code since the button always shows directly over the text (when enabled).

If we were to enable this button by default for code listings (which I personally think would be great), we may want to consider only showing it when hovering over the listing to try and eliminate some of that distraction maybe? That's just my personal opinion though. We could also consider a different layout where the button doesn't appear directly over the text as an alternative, although the positioning could get tricky in that case.

As a concrete example, 2 of the 3 code listings on this screenshot from the PR description are obscured by the button, regardless of where the user focus is:
476812913-bbc26127-bcb7-43b0-84f6-c52a95985040

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that it’s nicer to show the copy button only when hovering over code blocks. Originally I had the button only show when hovering over code blocks. However, Joe and I got some feedback on the forums that it’d be better for mobile use to have the button always visible.

I think I’ve got a decent solution here in my latest commit. I’m using @media (hover: hover) for devices that support hover and I added @media (hover: none), when hover isn’t supported, to set the button to be always visible. Let me know what you think.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to share the same pictures from the forums, here's what those changes look like on desktop and mobile.
show on hover
always show button mobile

v-if="copyToClipboard"
class="copy-button"
:class="{ copied: isCopied }"
@click="copyCodeToClipboard"
aria-label="Copy code to clipboard"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we extract the "Copy code to clipboard" string to a new key-value pair in src/lang/locales/en-US.json so that we could more easily translate this text for other languages in the future?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added the string to en-US.json, but I’m not entirely sure it’s added correctly. Could you double check and adjust if needed or let me know the correct approach?

title="Copy code to clipboard"
>
<CopyIcon v-if="!isCopied" class="copy-icon"/>
<CheckmarkIcon v-if="isCopied" class="checkmark-icon"/>
</button>
<!-- Do not add newlines in <pre>, as they'll appear in the rendered HTML. -->
<pre><CodeBlock><template
v-for="(line, index) in syntaxHighlightedLines"
Expand All @@ -45,16 +56,24 @@
import { escapeHtml } from 'docc-render/utils/strings';
import Language from 'docc-render/constants/Language';
import CodeBlock from 'docc-render/components/CodeBlock.vue';
import CopyIcon from 'docc-render/components/Icons/CopyIcon.vue';
import CheckmarkIcon from 'docc-render/components/Icons/CheckmarkIcon.vue';
import { highlightContent, registerHighlightLanguage } from 'docc-render/utils/syntax-highlight';

import CodeListingFilename from './CodeListingFilename.vue';

export default {
name: 'CodeListing',
components: { Filename: CodeListingFilename, CodeBlock },
components: {
Filename: CodeListingFilename,
CodeBlock,
CopyIcon,
CheckmarkIcon,
},
data() {
return {
syntaxHighlightedLines: [],
isCopied: false,
};
},
props: {
Expand All @@ -69,6 +88,10 @@ export default {
type: Array,
required: true,
},
copyToClipboard: {
type: Boolean,
default: () => false,
},
startLineNumber: {
type: Number,
default: () => 1,
Expand All @@ -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: {
Expand Down Expand Up @@ -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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a pretty minor detail since I think this API is pretty well supported at this point, but I wonder if we should also show a failure icon in the UI to make this failure less silent/hidden?

Just pseudo code, but maybe it could be something like:

async copyCodeToClipboard() {
  try {
    await navigator.clipboard.writeText(copyableText);
    this.copyingState = CopyingState.succeeded;  
  } catch (err) {
    console.error(...);
    this.copyingState = CopyingState.failed;
  } finally {
    this.setTimeout(() => {
      this.copyingState = null;
    }, duration);
  }
}

(The state could then just be used to toggle which version of the icon to show at any given time)

This probably isn't a blocking issue since the copy functionality is pretty widespread at this point and unlikely to fail—just thinking out loud mostly.

));
},
},
};
</script>
Expand Down Expand Up @@ -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;
Expand All @@ -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);
}

</style>
32 changes: 32 additions & 0 deletions src/components/Icons/CheckmarkIcon.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<!--
This source file is part of the Swift.org open source project
Copyright (c) 2025 Apple Inc. and the Swift project authors
Licensed under Apache License v2.0 with Runtime Library Exception
See https://swift.org/LICENSE.txt for license information
See https://swift.org/CONTRIBUTORS.txt for Swift project authors
-->

<template>
<svg
class="checkmark-icon"
viewBox="0 0 24 24"
>
<path d="M9 16.2L4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4L9 16.2z"/>
</svg>
</template>

<script>
export default {
name: 'CheckmarkIcon',
};
</script>

<style scoped lang="scss">
.checkmark-icon {
opacity: 1;
stroke: currentColor;
fill: currentColor;
}
</style>
34 changes: 34 additions & 0 deletions src/components/Icons/CopyIcon.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<!--
This source file is part of the Swift.org open source project

Copyright (c) 2025 Apple Inc. and the Swift project authors
Licensed under Apache License v2.0 with Runtime Library Exception

See https://swift.org/LICENSE.txt for license information
See https://swift.org/CONTRIBUTORS.txt for Swift project authors
-->

<template>
<svg
class="copy-icon"
viewBox="0 0 24 24"
>
<title>{{ $t('icons.copy') }}</title>
<path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2
.9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0
16H8V7h11v14z"/>
</svg>
</template>

<script>
export default {
name: 'CopyIcon',
};
</script>

<style scoped lang="scss">
.copy-icon {
fill: currentColor;
opacity: 0.8;
}
</style>
3 changes: 2 additions & 1 deletion src/lang/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -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})",
Expand Down
25 changes: 25 additions & 0 deletions tests/unit/components/ContentNode.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ describe('ContentNode', () => {
syntax: 'swift',
fileType: 'swift',
code: ['foobar'],
copyToClipboard: false,
};

it('renders a `CodeListing`', () => {
Expand All @@ -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);
});

Expand Down Expand Up @@ -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 = {
Expand Down
26 changes: 26 additions & 0 deletions tests/unit/components/ContentNode/CodeListing.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down