This repository has no description
1<script lang="ts">
2 import type { KeyboardEventHandler } from "svelte/elements";
3 import Eye from "$icon/eye";
4 import Pencil from "$icon/pencil";
5 import Button from "./Button.svelte";
6 import ButtonGroup, { segmentProps } from "./ButtonGroup.svelte";
7 import Textarea, { textareaMinHeight } from "./Textarea.svelte";
8 import { renderMarkup, type MarkupContext } from "$lib/markup";
9
10 interface Props {
11 value?: string;
12 markup: MarkupContext;
13 tab?: "write" | "preview";
14 id?: string;
15 name?: string;
16 rows?: number;
17 placeholder?: string;
18 disabled?: boolean;
19 autofocus?: boolean;
20 // drop the textarea/preview background so the editor blends into its surroundings
21 transparent?: boolean;
22 }
23
24 let {
25 value = $bindable(""),
26 markup,
27 tab = $bindable("write"),
28 id,
29 name,
30 rows = 12,
31 placeholder,
32 disabled = false,
33 autofocus = false,
34 transparent = false
35 }: Props = $props();
36
37 const surface = $derived(transparent ? "bg-transparent" : "bg-background-default");
38 // the preview pane replaces the field in place, so it has to carry the field's own
39 // height and inset — otherwise the whole editor resizes every time you switch tabs.
40 const pane = `rounded border border-border-default px-2.5 py-2 ${textareaMinHeight}`;
41
42 let previewHtml = $state<string | null>(null);
43 let previewing = $state(false);
44 let textareaEl = $state<HTMLTextAreaElement>();
45
46 // focus on mount (and when returning to the write tab) if requested
47 $effect(() => {
48 if (autofocus) textareaEl?.focus();
49 });
50
51 $effect(() => {
52 if (tab !== "preview") return;
53 const source = value;
54 if (!source.trim()) {
55 previewHtml = null;
56 previewing = false;
57 return;
58 }
59 let cancelled = false;
60 previewing = true;
61 renderMarkup(source, markup)
62 .then((html) => {
63 if (!cancelled) previewHtml = html;
64 })
65 .catch(() => {
66 if (!cancelled) previewHtml = null;
67 })
68 .finally(() => {
69 if (!cancelled) previewing = false;
70 });
71 return () => {
72 cancelled = true;
73 };
74 });
75 // ctrl/cmd+enter submits the enclosing form, mirroring the old htmx editor
76 const handleKeydown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
77 if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
78 e.preventDefault();
79 e.currentTarget.form?.requestSubmit();
80 }
81 };
82</script>
83
84<div class="flex flex-col gap-1.5">
85 <ButtonGroup class="self-start">
86 <Button
87 type="button"
88 size="sm"
89 icon={Pencil}
90 {...segmentProps(tab === "write")}
91 onclick={() => (tab = "write")}
92 >
93 Write
94 </Button>
95 <Button
96 type="button"
97 size="sm"
98 icon={Eye}
99 {...segmentProps(tab === "preview")}
100 onclick={() => (tab = "preview")}
101 >
102 Preview
103 </Button>
104 </ButtonGroup>
105
106 {#if tab === "write"}
107 <Textarea
108 {id}
109 {name}
110 {rows}
111 resizeable
112 {placeholder}
113 bind:value
114 bind:element={textareaEl}
115 {disabled}
116 onkeydown={handleKeydown}
117 class={transparent ? "bg-transparent" : undefined}
118 />
119 {:else if previewHtml}
120 <div class={`markup ${pane} ${surface}`}>
121 <!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in $lib/markup -->
122 {@html previewHtml}
123 </div>
124 {:else}
125 <div class={`${pane} ${surface} text-sm text-foreground-subtle italic`}>
126 {previewing ? "Rendering…" : "Nothing to preview."}
127 </div>
128 {/if}
129</div>