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 5 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
129 changes: 128 additions & 1 deletion src/components/ContentNode/CodeListing.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,37 @@
:fileType="fileType"
>{{ fileName }}
</Filename>
<div class="container-general">
<div class="container-general" ref="scrollContainer">
<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"
v-show="showCopyButton"
class="copy-button"
ref="copyButton"
:class="{ copied: isCopied, visible: buttonPositioned }"
@click="copyCodeToClipboard"
@update="handleScroll"
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?

>
<svg
Copy link
Contributor

Choose a reason for hiding this comment

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

It would be great if we could extract both of these icons to their own Vue components, similar to how it has been done for other svg icons.

There isn't anything wrong with how you've done it inline here, but with the components we could more easily re-use them in other places at a later point, and we could introduce a customization point so the actual assets would be easier to swap with theming as well.

Copy link
Author

Choose a reason for hiding this comment

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

I separated the icons into separate components like you asked. However, I couldn’t get them to work with the SVGIcon component without messing up the CSS. Is there a trick I'm missing?

v-if="!isCopied"
xmlns="http://www.w3.org/2000/svg"
viewbox="0 0 24 24"
width="24"
height="24"
fill="currentColor"
><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>
<svg
v-if="isCopied"
xmlns="http://www.w3.org/2000/svg"
viewbox="0 0 24 24"
width="24"
height="24"
fill="currentColor"
><path d="M9 16.2L4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4L9 16.2z"/>
</svg>
</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 @@ -42,6 +72,7 @@
</template>

<script>
import debounce from 'docc-render/utils/debounce';
import { escapeHtml } from 'docc-render/utils/strings';
import Language from 'docc-render/constants/Language';
import CodeBlock from 'docc-render/components/CodeBlock.vue';
Expand All @@ -55,8 +86,21 @@ export default {
data() {
return {
syntaxHighlightedLines: [],
isCopied: false,
showCopyButton: true,
buttonPositioned: false,
scrollTimeout: null,
};
},
mounted() {
this.$nextTick(() => {
this.updateCopyButtonPosition();
const container = this.$refs.scrollContainer;
if (container) {
container.addEventListener('scroll', this.handleScroll, { passive: true });
}
});
},
props: {
fileName: String,
isFileNameActionable: {
Expand All @@ -69,6 +113,10 @@ export default {
type: Array,
required: true,
},
copyToClipboard: {
type: Boolean,
required: true,
},
startLineNumber: {
type: Number,
default: () => 1,
Expand Down Expand Up @@ -122,6 +170,43 @@ export default {
line === '' ? '\n' : line
));
},
updateCopyButtonPosition() {
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm thinking there might be a simpler way to position this element using pure CSS that would eliminate the need for refs and any custom JS listening to events, etc

As a very basic example, this is some minimal CSS that could be used to position a small red box with the letter "A" in the top-right corner of a given code listing. Maybe you could check this out and explore a CSS based solution to the layout and position for the icon/button to avoid the need for scroll event listeners?

diff --git a/src/components/ContentNode/CodeListing.vue b/src/components/ContentNode/CodeListing.vue
index 8f1524fc..ba7ceebc 100644
--- a/src/components/ContentNode/CodeListing.vue
+++ b/src/components/ContentNode/CodeListing.vue
@@ -129,6 +129,19 @@ export default {
 <style scoped lang="scss">
 @import 'docc-render/styles/_core.scss';
 
+.code-listing::after {
+  align-items: center;
+  background: red;
+  color: white;
+  content: 'A';
+  display: flex;
+  height: 25px;
+  justify-content: center;
+  position: absolute;
+  right: 0;
+  width: 25px;
+}
+
 .code-line-container {
   display: inline-block;
   width: 100%;

(The important pieces there being the position: absolute and right: 0)

Copy link
Author

Choose a reason for hiding this comment

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

Thank you for this suggestion! I'll try to get the button positioning working with pure CSS and I'm glad it's feasible. I was concerned about the performance implications with the scroll event listeners.

const container = this.$refs.scrollContainer;
const button = this.$refs.copyButton;

if (!container || !button) return;

const { scrollLeft } = container;

button.style.transform = `translateX(${scrollLeft}px)`;
this.buttonPositioned = true;
},
handleScroll: debounce(function handleScroll() {
this.showCopyButton = false;
this.updateCopyButtonPosition();

if (this.scrollTimeout) {
clearTimeout(this.scrollTimeout);
}

this.scrollTimeout = window.setTimeout(() => {
this.showCopyButton = true;
}, 500);
}, 100),
copyCodeToClipboard() {
const lines = this.content;
const text = lines.join('\n');
navigator.clipboard.writeText(text)
.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 @@ -198,11 +283,53 @@ code {

.container-general {
overflow: auto;
position: relative;
}

.container-general,
pre {
flex-grow: 1;
}

.copy-button {
position: absolute;
top: 0.5em;
right: 0.5em;
transform: translateX(0);
background: var(--color-fill-gray-tertiary);
border: none;
border-radius: 6px;
padding: 7px 6px;
cursor: pointer;
display: none;
opacity: 0;
transition: all 0.2s ease-in-out;
}

.copy-button.visible {
opacity: 1;
}

.copy-button svg {
width: 24px;
height: 24px;
opacity: 0.8;
}

.copy-button:hover {
background-color: var(--color-fill-gray);
}

.copy-button:hover svg {
opacity: 1;
}

.copy-button.copied svg {
color: var(--color-figure-blue);
}

.container-general:hover .copy-button {
display: flex;
}

</style>
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