dsec-notebook/src/lib/components/MarkdownEditor.svelte
2026-09-01 21:32:51 +08:00

203 lines
5.1 KiB
Svelte

<script lang="ts">
import { tick } from "svelte";
import Markdown from "./Markdown.svelte";
let {
content = $bindable(""),
label = "Content",
placeholder = "",
rows = 10,
}: {
content?: string;
label?: string;
placeholder?: string;
rows?: number;
} = $props();
let textarea: HTMLTextAreaElement | undefined = $state();
let mode = $state<"write" | "preview">("write");
function selection(): { start: number; end: number; text: string } {
if (!textarea) return { start: content.length, end: content.length, text: "" };
const start = textarea.selectionStart ?? content.length;
const end = textarea.selectionEnd ?? content.length;
return { start, end, text: content.slice(start, end) };
}
function applyEdit(
build: (
value: string,
start: number,
end: number,
) => {
value: string;
start: number;
end: number;
},
) {
const { start, end } = selection();
const result = build(content, start, end);
content = result.value;
void tick().then(() => {
if (!textarea) return;
textarea.focus();
textarea.setSelectionRange(result.start, result.end);
});
}
function wrap(before: string, after = before) {
const { start, end, text } = selection();
applyEdit((value) => {
const next = value.slice(0, start) + before + text + after + value.slice(end);
return {
value: next,
start: start + before.length,
end: start + before.length + text.length,
};
});
}
function prefixLines(prefix: string) {
const { start, end } = selection();
applyEdit((value) => {
const lineStart = value.lastIndexOf("\n", start - 1) + 1;
let lineEnd = end;
if (start === end) {
const nextNewline = value.indexOf("\n", end);
lineEnd = nextNewline === -1 ? value.length : nextNewline;
}
const block = value.slice(lineStart, lineEnd);
const prefixed = block
.split("\n")
.map((line) => prefix + line)
.join("\n");
return {
value: value.slice(0, lineStart) + prefixed + value.slice(lineEnd),
start: lineStart,
end: lineStart + prefixed.length,
};
});
}
function insertLink() {
const { start, end, text } = selection();
const label = text || "link text";
const insertion = `[${label}](url)`;
applyEdit((value) => {
const next = value.slice(0, start) + insertion + value.slice(end);
const selectStart = start + insertion.length - 4;
const selectEnd = start + insertion.length - 1;
return { value: next, start: selectStart, end: selectEnd };
});
}
function insertCodeBlock() {
const { start, end, text } = selection();
applyEdit((value) => {
const next = value.slice(0, start) + "```\n" + text + "\n```" + value.slice(end);
return {
value: next,
start: start + 4,
end: start + 4 + text.length,
};
});
}
</script>
<div>
{#if label}
<label for="markdown-editor" class="kicker mb-2 block">{label}</label>
{/if}
<div class="border-l border-t border-r border-rule w-fit">
<button
type="button"
class="{mode === 'write'
? 'text-primary'
: 'hover:text-primary'} text-sm px-2 py-2 border-r border-rule"
onclick={() => (mode = "write")}
>
Write
</button>
<button
type="button"
class="{mode === 'preview'
? 'text-primary'
: 'hover:text-primary'} text-sm px-2 py-2"
onclick={() => (mode = "preview")}
>
Preview
</button>
</div>
{#if mode === "write"}
<div class="border-rule bg-surface flex flex-wrap items-center gap-1 rounded-sm border p-1">
<button type="button" class="editor-tool" title="Bold" onclick={() => wrap("**")}>
<strong>B</strong>
</button>
<button type="button" class="editor-tool" title="Italic" onclick={() => wrap("_")}>
<em>I</em>
</button>
<button
type="button"
class="editor-tool"
title="Strikethrough"
onclick={() => wrap("~~")}
>
<s>S</s>
</button>
<button type="button" class="editor-tool" title="Inline code" onclick={() => wrap("`")}>
&lt;/&gt;
</button>
<button
type="button"
class="editor-tool"
title="Heading"
onclick={() => prefixLines("## ")}
>
H
</button>
<button type="button" class="editor-tool" title="Link" onclick={insertLink}>🔗</button>
<button type="button" class="editor-tool" title="Code block" onclick={insertCodeBlock}>
{`{ }`}
</button>
<button
type="button"
class="editor-tool"
title="Bullet list"
onclick={() => prefixLines("- ")}
>
• List
</button>
<button
type="button"
class="editor-tool"
title="Blockquote"
onclick={() => prefixLines("> ")}
>
</button>
</div>
<textarea
id="markdown-editor"
bind:this={textarea}
bind:value={content}
{rows}
{placeholder}
class="field resize-y"></textarea>
<p class="text-faint mt-2 text-xs">
Markdown supported: **bold**, _italic_, `code`, [links](url), lists, quotes and code
blocks.
</p>
{:else}
<div
class="border-rule bg-surface text-ink min-h-40 rounded-sm border px-3 py-2.5 text-sm leading-relaxed"
>
{#if content.trim()}
<Markdown {content} />
{:else}
<p class="text-faint">Nothing to preview yet.</p>
{/if}
</div>
{/if}
</div>