-
Notifications
You must be signed in to change notification settings - Fork 64
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
base: main
Are you sure you want to change the base?
Changes from all commits
5b424bb
69a9d40
9fe7c28
c2abb95
cce81b7
17f7572
14914c2
09210b6
97352b7
a5c7019
75c56fc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 | ||
|
@@ -22,6 +22,17 @@ | |
>{{ fileName }} | ||
</Filename> | ||
<div class="container-general"> | ||
<button | ||
v-if="copyToClipboard" | ||
class="copy-button" | ||
:class="{ copied: isCopied }" | ||
@click="copyCodeToClipboard" | ||
aria-label="Copy code to clipboard" | ||
DebugSteven marked this conversation as resolved.
Show resolved
Hide resolved
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we extract the There was a problem hiding this comment. Choose a reason for hiding this commentThe 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" | ||
|
@@ -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: { | ||
|
@@ -69,6 +88,10 @@ export default { | |
type: Array, | ||
required: true, | ||
}, | ||
copyToClipboard: { | ||
type: Boolean, | ||
default: () => 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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
(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> | ||
|
@@ -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; | ||
DebugSteven marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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> |
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> |
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> |
There was a problem hiding this comment.
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:

There was a problem hiding this comment.
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.There was a problem hiding this comment.
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.

