feat(ai): add controls on code blocks

This commit is contained in:
Yann Amsellem
2025-04-09 18:45:15 +02:00
parent d8eb0731e1
commit 65c811ac62
8 changed files with 194 additions and 12 deletions

View File

@@ -5,7 +5,7 @@
import CircleStack from '$lib/icons/CircleStack.svelte';
import CircleStopSolid from '$lib/icons/CircleStopSolid.svelte';
import Plus from '$lib/icons/Plus.svelte';
import { transform } from '$lib/markdown';
import { getTextFromElement, transform } from '$lib/markdown';
import type { Table } from '$lib/olap-engine';
import DatasetsBox from './DatasetsBox.svelte';
import Loader from './Loader.svelte';
@@ -16,13 +16,15 @@
onClearConversation?: () => void;
datasets: Table[];
dataset?: Table;
onOpenInEditor?: (sql: string) => void;
}
let {
messages = $bindable([]),
onClearConversation,
datasets,
dataset = $bindable()
dataset = $bindable(),
onOpenInEditor
}: Props = $props();
let loading = $state(false);
@@ -92,6 +94,32 @@
abortController = undefined;
}
}
async function handleClick(e: Event) {
if ((e.target as HTMLButtonElement).classList.contains('copy')) {
const parent = e
.composedPath()
.find((node) => (node as HTMLElement).classList.contains('code-block')) as HTMLElement;
if (!parent) return;
const code = parent.querySelector('pre code') as HTMLElement;
if (!code) return;
navigator.clipboard.writeText(getTextFromElement(code));
}
if ((e.target as HTMLButtonElement).classList.contains('open')) {
const parent = e
.composedPath()
.find((node) => (node as HTMLElement).classList.contains('code-block')) as HTMLElement;
if (!parent) return;
const code = parent.querySelector('pre code') as HTMLElement;
if (!code) return;
onOpenInEditor?.(getTextFromElement(code));
}
}
</script>
{#snippet context(dataset: Table)}
@@ -115,7 +143,7 @@
{/if}
</h2>
{#if index === 0 && dataset}{@render context(dataset)}{/if}
<p class="markdown">
<p class="markdown" onclickcapture={handleClick}>
{@html transform(content)}
</p>
</article>

View File

@@ -11,9 +11,16 @@
chats?: Chat[];
focused?: number;
onCloseAllTab?: () => void;
onOpenInEditor?: (sql: string) => void;
}
let { chats = $bindable([]), focused = $bindable(0), datasets, onCloseAllTab }: Props = $props();
let {
chats = $bindable([]),
focused = $bindable(0),
datasets,
onCloseAllTab,
onOpenInEditor
}: Props = $props();
const current = $derived(chats.at(focused));
let scrollContainer = $state<HTMLElement>();
@@ -53,6 +60,7 @@
{datasets}
bind:messages={current.messages}
onClearConversation={() => (current.messages = [])}
{onOpenInEditor}
/>
{/if}
</div>

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="#e6e6e6" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>

After

Width:  |  Height:  |  Size: 326 B

3
src/lib/icons/check.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="#e6e6e6" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>

After

Width:  |  Height:  |  Size: 215 B

3
src/lib/icons/copy.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="#e6e6e6" viewBox="0 0 256 256">
<path d="M216,32H88a8,8,0,0,0-8,8V80H40a8,8,0,0,0-8,8V216a8,8,0,0,0,8,8H168a8,8,0,0,0,8-8V176h40a8,8,0,0,0,8-8V40A8,8,0,0,0,216,32ZM160,208H48V96H160Zm48-48H176V88a8,8,0,0,0-8-8H96V48H208Z"></path>
</svg>

After

Width:  |  Height:  |  Size: 308 B

View File

@@ -1,18 +1,45 @@
import hljs from 'highlight.js';
import { Marked } from 'marked';
import { markedHighlight } from 'marked-highlight';
import './markdown.css';
export function transform(content: string) {
const marked = new Marked(
markedHighlight({
langPrefix: 'hljs language-',
highlight(code, lang, _info) {
const marked = new Marked({
renderer: {
code({ text, lang = 'text' }) {
const language = hljs.getLanguage(lang) ? lang : 'text';
return hljs.highlight(code, { language }).value;
let html = '<div class="code-block">';
if (lang === 'sql') {
html += '<div class="controls">';
html += '<button type="button" class="copy" title="copy to clipboard"></button>';
html += '<button type="button" class="open" title="open in editor"></button>';
html += '</div>';
}
html += `<pre><code class="hljs language-${language}">`;
html += hljs.highlight(text, { language }).value;
html += '</code></pre>';
html += '</div>';
return html;
}
})
);
}
});
return marked.parse(content, { async: false });
}
export function getTextFromElement(element: HTMLElement) {
let result = '';
for (const child of element.childNodes ?? []) {
if (child.nodeType === Node.TEXT_NODE) {
result += (child as Text).data;
}
if (child.nodeType === Node.ELEMENT_NODE) {
result += getTextFromElement(child as HTMLElement);
}
}
return result;
}

View File

@@ -64,6 +64,103 @@
word-spacing: normal;
}
.code-block {
position: relative;
&:hover > .controls {
opacity: 1;
}
.controls {
opacity: 0;
transition: opacity 0.2s;
z-index: 1;
position: absolute;
top: 6px;
right: 6px;
display: flex;
align-items: center;
justify-content: end;
& > button {
height: 20px;
aspect-ratio: 1;
border: 1px solid hsl(0 0% 20%);
border-radius: 4px;
background-color: hsl(0deg 0% 5%);
&:hover {
background-color: hsl(0deg 0% 20%);
}
&:has(~ button) {
border-bottom-right-radius: 0;
border-top-right-radius: 0;
}
& ~ button {
border-left: none;
border-bottom-left-radius: 0;
border-top-left-radius: 0;
}
&.copy {
position: relative;
&::before,
&::after {
content: '';
display: block;
position: absolute;
width: 100%;
height: 100%;
left: 0;
top: 0;
background: no-repeat 50% 50% / 60% 60%;
transition: opacity 0.2s;
transition-delay: 0.6s;
}
&::before {
background-image: url($lib/icons/copy.svg);
}
&::after {
background-image: url($lib/icons/check.svg);
opacity: 0;
}
&:active::before {
opacity: 0;
transition: none;
}
&:active::after {
opacity: 1;
transition: none;
}
}
&.open {
position: relative;
&::before {
content: '';
display: block;
position: absolute;
width: 100%;
height: 100%;
left: 0;
top: 0;
background: no-repeat 50% 50% / 60% 60%;
background-image: url($lib/icons/arrow-top-right-on-square.svg);
}
}
}
}
}
.hljs-attribute,
.hljs-doctag,
.hljs-keyword,
@@ -72,4 +169,16 @@
.hljs-selector-tag {
font-weight: normal;
}
button {
appearance: none;
outline: none;
border: none;
background: none;
padding: 0;
&:not(:disabled):hover {
cursor: pointer;
}
}
}

View File

@@ -390,6 +390,7 @@ LIMIT 100;`;
if (isMobile) rightDrawerOpened = false;
else rightPanel.open = false;
}}
onOpenInEditor={openNewTabIfNeeded}
/>
{/snippet}