Enhancing Code Block Usability: A Sticky Copy Button
This document details the process of adding a "Copy to Clipboard" button to code blocks within messages from the LLM in our chatbot application. It highlights an initial implementation problem and the subsequent solution to ensure the button remains consistently visible, even when the code content requires horizontal scrolling.
1. The Goal
The primary objective was to improve the user experience for users interacting with code snippets provided by the LLM. Manually selecting and copying code can be tedious, especially for large blocks. A readily available "Copy" button would streamline this process, making code interaction more efficient.
2. Initial Implementation: The Problematic Approach
Our first attempt involved integrating the copy button directly into the existing <pre>
(preformatted text) elements that render the code blocks.
2.1. What I Did (Initial Implementation)
I aimed to place a "Copy" button in the top-right corner of each code block.
Styling (message-bubble.component.ts
component's styles
property):
I added CSS rules to position the button. The key was making the <pre>
element position: relative;
so that the position: absolute;
of the button would be relative to the code block itself.
// Inside @Component styles array in message-bubble.component.ts
styles: [
`
.message-content ::ng-deep pre {
position: relative; /* This makes the pre element the positioning context */
/* ... other pre styles like background, padding, overflow ... */
}
.message-content ::ng-deep .copy-button {
position: absolute;
top: 12px;
right: 12px;
background-color: rgba(255, 255, 255, 0.1);
color: white;
border: 1px solid rgba(255, 255, 255, 0.2);
padding: 6px 10px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
opacity: 0; /* Hidden by default */
transition: opacity 0.2s ease-in-out, background-color 0.2s ease;
}
.message-content ::ng-deep pre:hover .copy-button {
opacity: 1; /* Show on hover of the pre element */
}
.message-content ::ng-deep .copy-button:hover {
background-color: rgba(255, 255, 255, 0.2);
}
.message-content ::ng-deep .copy-button:active {
background-color: rgba(255, 255, 255, 0.3);
}
/* ... other component styles ... */
`
]
TypeScript Logic (message-bubble.component.ts
):
In the highlightCode
method, which executes after the message content is rendered, i dynamically created and attached the button to each <pre>
element.
// Inside message-bubble.component.ts
export class MessageBubbleComponent implements AfterViewInit {
// ... existing properties and methods ...
private highlightCode(): void {
if (this.messageContent && !this.message.isUser) {
// Select all <pre> elements that contain <code>
const codeBlocks = this.messageContent.nativeElement.querySelectorAll('pre');
codeBlocks.forEach((preElement: HTMLElement) => {
const codeElement = preElement.querySelector('code');
if (codeElement) {
// Highlight the code using an external service (Prism.js in this case)
this.prismService.highlightElement(codeElement);
// Add the copy button
this.addCopyButton(preElement, codeElement);
}
});
}
}
private addCopyButton(preElement: HTMLElement, codeElement: HTMLElement): void {
const copyButton = document.createElement('button');
copyButton.className = 'copy-button';
copyButton.textContent = 'Copy';
copyButton.addEventListener('click', () => this.copyToClipboard(codeElement, copyButton));
// IMPORTANT: This line was present or equivalent CSS ensured position:relative on pre
// preElement.style.position = 'relative'; // If not already set by CSS
// Append the button directly to the <pre> element
preElement.appendChild(copyButton);
}
private copyToClipboard(codeElement: HTMLElement, button: HTMLButtonElement): void {
const textToCopy = codeElement.innerText;
navigator.clipboard.writeText(textToCopy).then(() => {
button.textContent = 'Copied!';
setTimeout(() => {
button.textContent = 'Copy';
}, 2000);
}).catch(err => {
console.error('Failed to copy text: ', err);
button.textContent = 'Error';
setTimeout(() => {
button.textContent = 'Copy';
}, 2000);
});
}
}
2.2. The Problem Encountered
The initial implementation worked well for code blocks that fit entirely within the message bubble's width. However, for long lines of code that caused the <pre>
element to generate a horizontal scrollbar, I observed a critical usability flaw:
The copy button would scroll horizontally along with the code content.
When a user scrolled the code block to the right to view hidden characters, the "Copy" button, positioned absolutely inside the <pre>
element, would also move to the right and eventually disappear off-screen. This defeated the purpose of a consistently available button.
Why it happened: When overflow-x: auto;
(or similar) causes an element like <pre>
to scroll internally, any position: absolute;
children within it are positioned relative to the scrolling content area of the parent, not its fixed visual boundaries. Thus, as the content scrolled, the absolute position of the button relative to that content box shifted too.
3. The Solution: Introducing a Wrapper Element
To ensure the copy button remained "sticky" in the top-right corner of the visible part of the code block, regardless of horizontal scrolling, I introduced a new intermediate HTML element: a div
wrapper.
3.1. What I Did (Corrected Implementation)
The core change was to wrap each <pre>
element within a new div
that would serve as the stable positioning context for the button.
Styling (message-bubble.component.ts
component's styles
property):
I modified the CSS to apply position: relative;
to the new wrapper instead of the <pre>
element. The button's absolute positioning now references this stable wrapper.
// Inside @Component styles array in message-bubble.component.ts
styles: [
`
/* Copy button for code blocks */
.message-content ::ng-deep .code-block-wrapper {
position: relative; /* This new wrapper is now the positioning context */
margin: 0.8em 0; /* Added margin to the wrapper for consistent spacing */
/* Note: the <pre> element itself no longer needs position: relative; */
}
.message-content ::ng-deep .copy-button {
position: absolute;
top: 12px;
right: 12px;
background-color: rgba(255, 255, 255, 0.1);
color: white;
border: 1px solid rgba(255, 255, 255, 0.2);
padding: 6px 10px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s ease-in-out, background-color 0.2s ease;
}
.message-content ::ng-deep .code-block-wrapper:hover .copy-button {
opacity: 1; /* Button now shows on hover of the new wrapper */
}
.message-content ::ng-deep .copy-button:hover {
background-color: rgba(255, 255, 255, 0.2);
}
.message-content ::ng-deep .copy-button:active {
background-color: rgba(255, 255, 255, 0.3);
}
/* ... other component styles ... */
`
]
TypeScript Logic (message-bubble.component.ts
):
The addCopyButton
method was significantly changed to create the wrapper, re-parent the <pre>
element, and then append the copy button to the newly created wrapper.
// Inside message-bubble.component.ts
export class MessageBubbleComponent implements AfterViewInit {
// ... existing properties and methods ...
private highlightCode(): void {
if (this.messageContent && !this.message.isUser) {
const codeBlocks = this.messageContent.nativeElement.querySelectorAll('pre');
codeBlocks.forEach((preElement: HTMLElement) => {
const codeElement = preElement.querySelector('code');
if (codeElement) {
this.prismService.highlightElement(codeElement);
this.addCopyButton(preElement, codeElement);
}
});
}
}
private addCopyButton(preElement: HTMLElement, codeElement: HTMLElement): void {
// 1. Create a new wrapper div
const wrapper = document.createElement('div');
wrapper.className = 'code-block-wrapper'; // Assign the new class
// 2. Insert the new wrapper into the DOM *before* the original <pre> element
// This ensures the wrapper takes the <pre>'s place structurally
preElement.parentNode?.insertBefore(wrapper, preElement);
// 3. Move (re-parent) the original <pre> element *inside* the new wrapper
wrapper.appendChild(preElement);
// 4. Create the copy button
const copyButton = document.createElement('button');
copyButton.className = 'copy-button';
copyButton.textContent = 'Copy';
copyButton.addEventListener('click', () => this.copyToClipboard(codeElement, copyButton));
// 5. Append the copy button to the *wrapper* (not the preElement)
wrapper.appendChild(copyButton);
}
private copyToClipboard(codeElement: HTMLElement, button: HTMLButtonElement): void {
// ... (same copy to clipboard logic as before) ...
}
}
3.2. Why It Works
By introducing the code-block-wrapper
:
- Stable Positioning Context: The
wrapper
div itself does not scroll horizontally. Whenposition: relative;
is applied to this wrapper, it creates a fixed, non-scrolling reference point for its children. - Independent Scrolling: The
<pre>
element, now a child of thewrapper
, is free to scroll its content horizontally without affecting the layout of its parent or sibling elements (like the copy button). - Fixed Button Position: The copy button, being
position: absolute;
relative to thewrapper
, remains anchored to the top-right corner of the wrapper's visible area, regardless of how much the<pre>
element's content scrolls internally.
4. Conclusion
The problem of the horizontally scrolling copy button was directly addressed by refining our DOM structure. By simply wrapping the <pre>
element within a new div
that acts as the positioning context, i achieved a truly "sticky" copy button. This small but effective change significantly improves the usability of code blocks in our chatbot, ensuring that the copy functionality is always readily accessible to the user, enhancing the overall user experience.