From 65c811ac6209f37a01bb8da8ea23a81186c1cdbb Mon Sep 17 00:00:00 2001 From: Yann Amsellem Date: Wed, 9 Apr 2025 18:45:15 +0200 Subject: [PATCH] feat(ai): add controls on code blocks --- src/lib/components/Ai/Chat.svelte | 34 +++++- src/lib/components/Ai/Panel.svelte | 10 +- src/lib/icons/arrow-top-right-on-square.svg | 3 + src/lib/icons/check.svg | 3 + src/lib/icons/copy.svg | 3 + src/lib/markdown/index.ts | 43 ++++++-- src/lib/markdown/markdown.css | 109 ++++++++++++++++++++ src/routes/+page.svelte | 1 + 8 files changed, 194 insertions(+), 12 deletions(-) create mode 100644 src/lib/icons/arrow-top-right-on-square.svg create mode 100644 src/lib/icons/check.svg create mode 100644 src/lib/icons/copy.svg diff --git a/src/lib/components/Ai/Chat.svelte b/src/lib/components/Ai/Chat.svelte index 39dd0bf..0a06353 100644 --- a/src/lib/components/Ai/Chat.svelte +++ b/src/lib/components/Ai/Chat.svelte @@ -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)); + } + } {#snippet context(dataset: Table)} @@ -115,7 +143,7 @@ {/if} {#if index === 0 && dataset}{@render context(dataset)}{/if} -

+

{@html transform(content)}

diff --git a/src/lib/components/Ai/Panel.svelte b/src/lib/components/Ai/Panel.svelte index 55a2cf2..17ab553 100644 --- a/src/lib/components/Ai/Panel.svelte +++ b/src/lib/components/Ai/Panel.svelte @@ -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(); @@ -53,6 +60,7 @@ {datasets} bind:messages={current.messages} onClearConversation={() => (current.messages = [])} + {onOpenInEditor} /> {/if} diff --git a/src/lib/icons/arrow-top-right-on-square.svg b/src/lib/icons/arrow-top-right-on-square.svg new file mode 100644 index 0000000..8387c51 --- /dev/null +++ b/src/lib/icons/arrow-top-right-on-square.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/lib/icons/check.svg b/src/lib/icons/check.svg new file mode 100644 index 0000000..ef5253e --- /dev/null +++ b/src/lib/icons/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/lib/icons/copy.svg b/src/lib/icons/copy.svg new file mode 100644 index 0000000..74347ba --- /dev/null +++ b/src/lib/icons/copy.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/lib/markdown/index.ts b/src/lib/markdown/index.ts index 265494b..5eb3372 100644 --- a/src/lib/markdown/index.ts +++ b/src/lib/markdown/index.ts @@ -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 = '
'; + if (lang === 'sql') { + html += '
'; + html += ''; + html += ''; + html += '
'; + } + html += `
`;
+				html += hljs.highlight(text, { language }).value;
+				html += '
'; + html += '
'; + + 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; +} diff --git a/src/lib/markdown/markdown.css b/src/lib/markdown/markdown.css index 04c8147..2944ca5 100644 --- a/src/lib/markdown/markdown.css +++ b/src/lib/markdown/markdown.css @@ -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; + } + } } diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 9481da0..775f27d 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -390,6 +390,7 @@ LIMIT 100;`; if (isMobile) rightDrawerOpened = false; else rightPanel.open = false; }} + onOpenInEditor={openNewTabIfNeeded} /> {/snippet}