Spaces:
Running
Running
Show preview + autosize textarea
Browse files
src/lib/actions/autosize.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const DEFAULT_MIN_ROWS = 1;
|
| 2 |
+
|
| 3 |
+
export function autosize(
|
| 4 |
+
node: HTMLTextAreaElement,
|
| 5 |
+
options?: { minRows?: number; maxRows?: number; value?: string }
|
| 6 |
+
) {
|
| 7 |
+
const minRows = options?.minRows ?? DEFAULT_MIN_ROWS;
|
| 8 |
+
const maxRows = options?.maxRows;
|
| 9 |
+
|
| 10 |
+
function resize() {
|
| 11 |
+
node.style.height = 'auto';
|
| 12 |
+
const lineHeight = parseInt(getComputedStyle(node).lineHeight, 10) || 24;
|
| 13 |
+
const minHeight = lineHeight * minRows;
|
| 14 |
+
let height = Math.max(minHeight, node.scrollHeight);
|
| 15 |
+
if (maxRows != null) {
|
| 16 |
+
const maxHeight = lineHeight * maxRows;
|
| 17 |
+
height = Math.min(height, maxHeight);
|
| 18 |
+
node.style.overflowY = height >= maxHeight ? 'auto' : 'hidden';
|
| 19 |
+
} else {
|
| 20 |
+
node.style.overflowY = '';
|
| 21 |
+
}
|
| 22 |
+
node.style.height = `${height}px`;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
node.addEventListener('input', resize);
|
| 26 |
+
resize();
|
| 27 |
+
|
| 28 |
+
return {
|
| 29 |
+
update() {
|
| 30 |
+
resize();
|
| 31 |
+
},
|
| 32 |
+
destroy() {
|
| 33 |
+
node.removeEventListener('input', resize);
|
| 34 |
+
node.style.height = 'auto';
|
| 35 |
+
}
|
| 36 |
+
};
|
| 37 |
+
}
|
src/lib/components/chat/User.svelte
CHANGED
|
@@ -27,6 +27,7 @@
|
|
| 27 |
import Welcome from './Welcome.svelte';
|
| 28 |
import ListModels from '$lib/components/model/ListModels.svelte';
|
| 29 |
import { breakpointsState } from '$lib/state/breakpoints.svelte';
|
|
|
|
| 30 |
|
| 31 |
let { id }: NodeProps = $props();
|
| 32 |
|
|
@@ -217,6 +218,7 @@
|
|
| 217 |
{:else}
|
| 218 |
<footer class="flex flex-col items-end transition-all duration-300">
|
| 219 |
<textarea
|
|
|
|
| 220 |
name="message"
|
| 221 |
id="message"
|
| 222 |
placeholder="Ask me anything..."
|
|
|
|
| 27 |
import Welcome from './Welcome.svelte';
|
| 28 |
import ListModels from '$lib/components/model/ListModels.svelte';
|
| 29 |
import { breakpointsState } from '$lib/state/breakpoints.svelte';
|
| 30 |
+
import { autosize } from '$lib/actions/autosize';
|
| 31 |
|
| 32 |
let { id }: NodeProps = $props();
|
| 33 |
|
|
|
|
| 218 |
{:else}
|
| 219 |
<footer class="flex flex-col items-end transition-all duration-300">
|
| 220 |
<textarea
|
| 221 |
+
use:autosize={{ minRows: 1, maxRows: 8, value: prompt }}
|
| 222 |
name="message"
|
| 223 |
id="message"
|
| 224 |
placeholder="Ask me anything..."
|
src/lib/components/chat/markdown/Code.svelte
CHANGED
|
@@ -5,6 +5,7 @@
|
|
| 5 |
import { mode } from 'mode-watcher';
|
| 6 |
import githubDarkUrl from 'svelte-highlight/styles/github-dark.css?url';
|
| 7 |
import githubUrl from 'svelte-highlight/styles/github.css?url';
|
|
|
|
| 8 |
|
| 9 |
let {
|
| 10 |
lang,
|
|
@@ -20,6 +21,19 @@
|
|
| 20 |
setTimeout(() => (copiedCode = false), 2000);
|
| 21 |
}
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
$effect(() => {
|
| 24 |
const themeUrl = mode.current === 'dark' ? githubDarkUrl : githubUrl;
|
| 25 |
let link = document.getElementById('highlight-theme') as HTMLLinkElement | null;
|
|
@@ -31,6 +45,10 @@
|
|
| 31 |
}
|
| 32 |
link.href = themeUrl;
|
| 33 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
</script>
|
| 35 |
|
| 36 |
<div class="overflow-hidden {className}">
|
|
@@ -38,22 +56,42 @@
|
|
| 38 |
<div
|
| 39 |
class="flex items-center justify-between border-b border-border/60 bg-muted px-3 py-1.5 dark:bg-accent/30"
|
| 40 |
>
|
| 41 |
-
<span class="font-mono text-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
</div>
|
| 43 |
{/if}
|
| 44 |
-
<div class="group relative">
|
| 45 |
-
<HighlightAuto code={text} class="font-mono text-sm leading-relaxed" />
|
| 46 |
-
<Button
|
| 47 |
-
variant="outline"
|
| 48 |
-
class="absolute top-2 right-2 opacity-0 group-hover:opacity-100"
|
| 49 |
-
size="icon-xs"
|
| 50 |
-
onclick={() => copy(text)}
|
| 51 |
-
>
|
| 52 |
-
{#if copiedCode}
|
| 53 |
-
<Check class="size-3.5 text-green-500" />
|
| 54 |
-
{:else}
|
| 55 |
-
<Copy class="size-3.5" />
|
| 56 |
-
{/if}
|
| 57 |
-
</Button>
|
| 58 |
-
</div>
|
| 59 |
</div>
|
|
|
|
| 5 |
import { mode } from 'mode-watcher';
|
| 6 |
import githubDarkUrl from 'svelte-highlight/styles/github-dark.css?url';
|
| 7 |
import githubUrl from 'svelte-highlight/styles/github.css?url';
|
| 8 |
+
import Switch from '$lib/components/ui/switch/switch.svelte';
|
| 9 |
|
| 10 |
let {
|
| 11 |
lang,
|
|
|
|
| 21 |
setTimeout(() => (copiedCode = false), 2000);
|
| 22 |
}
|
| 23 |
|
| 24 |
+
function hasStrictHtml5Doctype(input: string): boolean {
|
| 25 |
+
if (!input) return false;
|
| 26 |
+
const withoutBOM = input.replace(/^\uFEFF/, '');
|
| 27 |
+
const trimmed = withoutBOM.trimStart();
|
| 28 |
+
// Strict HTML5 doctype: <!doctype html> with optional whitespace before >
|
| 29 |
+
return /^<!doctype\s+html\s*>/i.test(trimmed);
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
function isSvgDocument(input: string): boolean {
|
| 33 |
+
const trimmed = input.trimStart();
|
| 34 |
+
return /^(?:<\?xml[^>]*>\s*)?(?:<!doctype\s+svg[^>]*>\s*)?<svg[\s>]/i.test(trimmed);
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
$effect(() => {
|
| 38 |
const themeUrl = mode.current === 'dark' ? githubDarkUrl : githubUrl;
|
| 39 |
let link = document.getElementById('highlight-theme') as HTMLLinkElement | null;
|
|
|
|
| 45 |
}
|
| 46 |
link.href = themeUrl;
|
| 47 |
});
|
| 48 |
+
|
| 49 |
+
let canShowPreview = $derived(hasStrictHtml5Doctype(text) || isSvgDocument(text));
|
| 50 |
+
// svelte-ignore state_referenced_locally
|
| 51 |
+
let showPreview = $state.raw(canShowPreview);
|
| 52 |
</script>
|
| 53 |
|
| 54 |
<div class="overflow-hidden {className}">
|
|
|
|
| 56 |
<div
|
| 57 |
class="flex items-center justify-between border-b border-border/60 bg-muted px-3 py-1.5 dark:bg-accent/30"
|
| 58 |
>
|
| 59 |
+
<span class="font-mono text-xs text-muted-foreground">
|
| 60 |
+
{#if showPreview}
|
| 61 |
+
Live Preview
|
| 62 |
+
{:else}
|
| 63 |
+
{lang}
|
| 64 |
+
{/if}
|
| 65 |
+
</span>
|
| 66 |
+
{#if canShowPreview}
|
| 67 |
+
<div class="flex items-center gap-1.5">
|
| 68 |
+
<p class="font-mono text-[10px] text-muted-foreground">
|
| 69 |
+
Show {showPreview ? 'Preview' : 'Code'}
|
| 70 |
+
</p>
|
| 71 |
+
<Switch bind:checked={showPreview} />
|
| 72 |
+
</div>
|
| 73 |
+
{/if}
|
| 74 |
+
</div>
|
| 75 |
+
{/if}
|
| 76 |
+
{#if showPreview}
|
| 77 |
+
<div class="group relative">
|
| 78 |
+
<iframe srcDoc={text} class="h-[500px] w-full" title="Preview"></iframe>
|
| 79 |
+
</div>
|
| 80 |
+
{:else}
|
| 81 |
+
<div class="group relative">
|
| 82 |
+
<HighlightAuto code={text} class="font-mono text-sm leading-relaxed" />
|
| 83 |
+
<Button
|
| 84 |
+
variant="outline"
|
| 85 |
+
class="absolute top-2 right-2 opacity-0 group-hover:opacity-100"
|
| 86 |
+
size="icon-xs"
|
| 87 |
+
onclick={() => copy(text)}
|
| 88 |
+
>
|
| 89 |
+
{#if copiedCode}
|
| 90 |
+
<Check class="size-3.5 text-green-500" />
|
| 91 |
+
{:else}
|
| 92 |
+
<Copy class="size-3.5" />
|
| 93 |
+
{/if}
|
| 94 |
+
</Button>
|
| 95 |
</div>
|
| 96 |
{/if}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
</div>
|