Skip to content
Merged
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
112 changes: 110 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,19 @@
>{{ 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
Contributor 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
Contributor 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

Copy link
Contributor

Choose a reason for hiding this comment

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

I think that's a pretty reasonable compromise for now, especially since this is only enabled with an experimental flag for now.

I think we may want to consider disabling it entirely or using an alternate layout for mobile/non-hover devices in the future due to the smaller benefit there weighed against the downside of how it obscures a lot of content.

Copy link
Contributor

Choose a reason for hiding this comment

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

Nice work! Glad to see the discussion on the forums.

v-if="copyToClipboard"
class="copy-button"
:class="copyState"
@click="copyCodeToClipboard"
:aria-label="$t('icons.copy')"
:title="$t('icons.copy')"
>
<CopyIcon v-if="copyState === CopyState.idle" class="copy-icon"/>
<CheckmarkIcon v-else-if="copyState === CopyState.success" class="checkmark-icon"/>
<CrossIcon v-else-if="copyState === CopyState.failure" class="cross-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 +58,33 @@
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 'theme/components/Icons/CopyIcon.vue';
import CheckmarkIcon from 'theme/components/Icons/CheckmarkIcon.vue';
import CrossIcon from 'theme/components/Icons/CrossIcon.vue';
import { highlightContent, registerHighlightLanguage } from 'docc-render/utils/syntax-highlight';

import CodeListingFilename from './CodeListingFilename.vue';

const CopyState = {
idle: 'idle',
success: 'success',
failure: 'failure',
};

export default {
name: 'CodeListing',
components: { Filename: CodeListingFilename, CodeBlock },
components: {
Filename: CodeListingFilename,
CodeBlock,
CopyIcon,
CheckmarkIcon,
CrossIcon,
},
data() {
return {
syntaxHighlightedLines: [],
copyState: CopyState.idle,
CopyState,
};
},
props: {
Expand All @@ -69,6 +99,10 @@ export default {
type: Array,
required: true,
},
copyToClipboard: {
type: Boolean,
default: () => false,
},
startLineNumber: {
type: Number,
default: () => 1,
Expand All @@ -92,6 +126,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 +159,21 @@ export default {
line === '' ? '\n' : line
));
},
copyCodeToClipboard() {
navigator.clipboard.writeText(this.copyableText)
.then(() => {
this.copyState = CopyState.success;
})
.catch((err) => {
console.error('Failed to copy text: ', err);
this.copyState = CopyState.failure;
})
.finally(() => {
setTimeout(() => {
this.copyState = CopyState.idle;
}, 1000);
});
},
},
};
</script>
Expand Down Expand Up @@ -187,6 +239,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 +258,59 @@ 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 .copy-icon {
fill: var(--color-figure-gray);
}

.copy-button.success .checkmark-icon {
color: var(--color-figure-blue);
fill: currentColor;
}

.copy-button.failure .cross-icon {
color: var(--color-figure-red);
fill: currentColor;
}

</style>
35 changes: 35 additions & 0 deletions src/components/Icons/CheckmarkIcon.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<!--
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>
<SVGIcon
class="CheckmarkIcon"
viewBox="0 0 24 24"
themeId="checkmark"
>
<path d="M9 16.2L4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4L9 16.2z"/>
</SVGIcon>
</template>

<script>
import SVGIcon from 'docc-render/components/SVGIcon.vue';

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

<style scoped lang="scss">
.CheckmarkIcon {
opacity: 1;
stroke: currentColor;
}
</style>
37 changes: 37 additions & 0 deletions src/components/Icons/CopyIcon.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<!--
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>
<SVGIcon
class="CopyIcon"
viewBox="0 0 24 24"
themeId="copy"
>
<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"/>
</SVGIcon>
</template>

<script>
import SVGIcon from 'docc-render/components/SVGIcon.vue';

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

<style scoped lang="scss">
.CopyIcon {
opacity: 0.8;
}
</style>
37 changes: 37 additions & 0 deletions src/components/Icons/CrossIcon.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<!--
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>
<SVGIcon
class="CrossIcon"
viewBox="0 0 24 24"
themeId="cross"
>
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41
10.59 12 5 17.59 6.41 19 12 13.41
17.59 19 19 17.59 13.41 12z"/>
</SVGIcon>
</template>

<script>
import SVGIcon from 'docc-render/components/SVGIcon.vue';

export default {
name: 'CrossIcon',
components: { SVGIcon },
};
</script>

<style scoped lang="scss">
.CrossIcon {
opacity: 1;
stroke: currentColor;
}
</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