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}