feat(ai): add controls on code blocks
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
3
src/lib/icons/arrow-top-right-on-square.svg
Normal file
3
src/lib/icons/arrow-top-right-on-square.svg
Normal 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
3
src/lib/icons/check.svg
Normal 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
3
src/lib/icons/copy.svg
Normal 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 |
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,6 +390,7 @@ LIMIT 100;`;
|
||||
if (isMobile) rightDrawerOpened = false;
|
||||
else rightPanel.open = false;
|
||||
}}
|
||||
onOpenInEditor={openNewTabIfNeeded}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user