Something went wrong. Try again.
Monorepo for Tangled
Something went wrong. Try again.
Svelte
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464<script module lang="ts"> import { tv, type VariantProps } from "tailwind-variants";
export const markdownEditor = tv({ slots: { root: "flex flex-col", toolbar: "flex items-center justify-between gap-md border-b border-border-default bg-background-subtle p-2xs", body: "relative flex [&:has([data-preview-ready])_textarea]:invisible", field: "rounded-none border-0 bg-transparent focus-within:ring-0" }, variants: { transparent: { true: { root: "bg-transparent" }, false: { root: "bg-background-default" } }, compact: { true: { body: "items-end gap-xs", field: "min-w-0 flex-1 typography-paragraph-regular [&_textarea]:px-0 [&_textarea]:py-2xs [&_textarea]:placeholder:text-foreground-muted" }, false: { root: "overflow-hidden transition-colors", body: "flex-col", field: "typography-paragraph-body" } }, inset: { true: "", false: "" }, disabled: { true: "", false: "" } }, compoundVariants: [ { compact: false, inset: false, class: { root: "rounded-sm border" } }, { compact: false, inset: true, class: { root: "relative z-1 rounded-t-[calc(var(--radius-sm)-1px)]" } }, { compact: false, disabled: true, class: { root: "cursor-not-allowed border-border-disabled" } }, { compact: false, disabled: false, class: { root: "border-border-default" } }, { compact: false, inset: false, disabled: false, class: { root: "focus-within:border-border-strong focus-within:ring-1 focus-within:ring-border-strong" } }, { compact: false, inset: true, disabled: false, class: { root: "outline-2 outline-transparent focus-within:outline-border-strong" } } ], defaultVariants: { transparent: false, compact: false, inset: false, disabled: false } });
export type MarkdownEditorVariants = VariantProps<typeof markdownEditor>;</script>
<script lang="ts"> import { onMount, untrack, type Component, type Snippet } from "svelte"; import type { Attachment } from "svelte/attachments"; import type { KeyboardEventHandler, SvelteHTMLElements } from "svelte/elements"; import Bold from "$icon/bold"; import ChevronDown from "$icon/chevron-down"; import Code from "$icon/code"; import Eye from "$icon/eye"; import ImageUp from "$icon/image-up"; import Italic from "$icon/italic"; import Link from "$icon/link"; import List from "$icon/list"; import ListOrdered from "$icon/list-ordered"; import ListTodo from "$icon/list-todo"; import SeparatorHorizontal from "$icon/separator-horizontal"; import SquareCode from "$icon/square-code"; import Strikethrough from "$icon/strikethrough"; import Table from "$icon/table"; import Button from "$lib/components/ui/Button.svelte"; import Dropdown from "$lib/components/ui/Dropdown.svelte"; import DropdownItem from "$lib/components/ui/DropdownItem.svelte"; import Kbd from "$lib/components/ui/Kbd.svelte"; import MarkdownTypeahead from "$lib/components/ui/MarkdownTypeahead.svelte"; import Pending from "$lib/components/ui/Pending.svelte"; import Textarea from "$lib/components/ui/Textarea.svelte"; import Toggle from "$lib/components/ui/Toggle.svelte"; import Tooltip from "$lib/components/ui/Tooltip.svelte"; import { applyEdit, code, codeBlock, image, link, list, separator, table, wrap, type Edit, type ListKind, type Selection } from "$lib/components/ui/markdownFormat"; import { editorLine, previewLine, scrollEditorToLine, scrollPreviewToLine } from "$lib/components/ui/markdownScroll"; import { renderMarkup, type MarkupContext } from "$lib/markup"; import { enhanceCodeblocks } from "$lib/markup/highlight";
interface Tool { label: string; icon: Component<SvelteHTMLElements["svg"]>; format: (selection: Selection) => Edit; key?: string; shift?: boolean; }
const INLINE_TOOLS: Tool[] = [ { label: "Bold", icon: Bold, key: "b", format: (s) => wrap(s, "**") }, { label: "Italic", icon: Italic, key: "i", format: (s) => wrap(s, "_") }, { label: "Strikethrough", icon: Strikethrough, key: "x", shift: true, format: (s) => wrap(s, "~~") }, { label: "Code", icon: Code, key: "e", format: code }, { label: "Link", icon: Link, key: "k", format: link }, { label: "Image", icon: ImageUp, format: image } ];
const BLOCK_TOOLS: Tool[] = [ { label: "Code block", icon: SquareCode, format: codeBlock }, { label: "Table", icon: Table, format: table }, { label: "Separator", icon: SeparatorHorizontal, format: separator } ];
const LISTS: { kind: ListKind; label: string; icon: Tool["icon"] }[] = [ { kind: "bullet", label: "Bulleted list", icon: List }, { kind: "ordered", label: "Numbered list", icon: ListOrdered }, { kind: "task", label: "Task list", icon: ListTodo } ];
interface Props { value?: string; markup: MarkupContext; preview?: boolean; id?: string; name?: string; rows?: number; placeholder?: string; disabled?: boolean; autofocus?: boolean; transparent?: boolean; compact?: boolean; inset?: boolean; trailing?: Snippet; }
let { value = $bindable(""), markup, preview = $bindable(false), id, name, rows = 12, placeholder, disabled = false, autofocus = false, transparent = false, compact = false, inset = false, trailing }: Props = $props();
const uid = $props.id(); const textareaId = $derived(id ?? `${uid}-textarea`); const previewId = `${uid}-preview`; const listId = `${uid}-suggestions`; const classes = $derived(markdownEditor({ transparent, compact, inset, disabled }));
let textareaEl = $state<HTMLTextAreaElement>(); let typeahead = $state<ReturnType<typeof MarkdownTypeahead>>(); let pane = $state<HTMLElement>(); let entryLine = 0; let alignedScroll = 0; let previewScrollLine: number | null = null; let mac = $state(false); let listOpen = $state(false);
onMount(() => { mac = /Mac|iPhone|iPad/.test(navigator.platform); });
$effect(() => { if (autofocus) textareaEl?.focus(); });
$effect(() => { const el = textareaEl; if (!compact || !el) return; void value; el.style.height = "auto"; el.style.height = `${el.scrollHeight}px`; });
const hasContent = $derived(value.trim() !== "");
const rendered = $derived( preview && hasContent ? renderMarkup(value, { ...markup, sourceLines: true }).then((html) => html ?? "") : null );
const shortcutKeys = (tool: Tool): string[] => tool.key ? [ mac ? "⌘" : "Ctrl", ...(tool.shift ? [mac ? "⇧" : "Shift"] : []), tool.key.toUpperCase() ] : [];
const ariaShortcut = (tool: Tool) => tool.key ? ["Control", "Meta"] .map((modifier) => [modifier, ...(tool.shift ? ["Shift"] : []), tool.key!.toUpperCase()].join( "+" ) ) .join(" ") : undefined;
const apply = (format: (selection: Selection) => Edit) => { if (!textareaEl) return; const { value: text, selectionStart: start, selectionEnd: end } = textareaEl; applyEdit(textareaEl, format({ value: text, start, end })); };
const handleKeydown: KeyboardEventHandler<HTMLTextAreaElement> = (event) => { if (typeahead?.keydown(event)) return; if (!(event.metaKey || event.ctrlKey) || event.altKey) return; if (event.key === "Enter") { event.preventDefault(); event.currentTarget.form?.requestSubmit(); return; } const key = event.key.toLowerCase(); const tool = INLINE_TOOLS.find((t) => t.key === key && !!t.shift === event.shiftKey); if (!tool) return; event.preventDefault(); apply(tool.format); };
let previewShown = untrack(() => preview);
$effect.pre(() => { const shown = preview; untrack(() => { if (shown === previewShown) return; previewShown = shown; typeahead?.close(); if (!textareaEl) return; if (shown) { entryLine = editorLine(textareaEl); alignedScroll = 0; previewScrollLine = null; } else if (previewScrollLine !== null) { scrollEditorToLine(textareaEl, previewScrollLine); } }); });
const alignPreview: Attachment<HTMLElement> = (node) => { let frame = 0; const align = () => { if (!node.isConnected || !pane) { frame = requestAnimationFrame(align); return; } untrack(() => { if (!pane) return; scrollPreviewToLine(pane, value, entryLine); alignedScroll = pane.scrollTop; }); }; align(); return () => cancelAnimationFrame(frame); };
const handlePreviewScroll = () => { if (!pane) return; previewScrollLine = pane.scrollTop === alignedScroll ? null : previewLine(pane, value); };
const handleKeyup = (event: KeyboardEvent) => { if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) typeahead?.refresh(); };</script>
{#snippet toolButton(tool: Tool)} {@const keys = shortcutKeys(tool)} <Tooltip bubbleClass={keys.length > 0 ? "pt-1" : undefined}> {#snippet content()} <span class="flex items-center gap-xs"> {tool.label} {#if keys.length > 0} <span class="flex items-center gap-3xs"> {#each keys as key (key)} <Kbd inverted>{key}</Kbd> {/each} </span> {/if} </span> {/snippet} <Button variant="ghost" size="mini" icon={tool.icon} aria-label={tool.label} aria-keyshortcuts={ariaShortcut(tool)} disabled={disabled || preview} onclick={() => apply(tool.format)} class="hit-area-x-1.5 overflow-visible" /> </Tooltip>{/snippet}
<div class={classes.root()}> {#if !compact} <div class={classes.toolbar()}> <div role="toolbar" aria-label="Formatting" aria-controls={textareaId} class="flex min-w-0 flex-wrap items-center gap-xs" > {#each INLINE_TOOLS as tool (tool.label)} {@render toolButton(tool)} {/each} <Tooltip content="List" disabled={listOpen}> <Dropdown variant="button" buttonVariant="ghost" size="mini" label="List" disabled={disabled || preview} onOpenChange={(open) => (listOpen = open)} > {#snippet trigger()} <List class="size-3 shrink-0" aria-hidden="true" /> <ChevronDown class="size-3 shrink-0" aria-hidden="true" /> {/snippet} {#each LISTS as item (item.kind)} <DropdownItem icon={item.icon} onclick={() => apply((s) => list(s, item.kind))} > {item.label} </DropdownItem> {/each} </Dropdown> </Tooltip> {#each BLOCK_TOOLS as tool (tool.label)} {@render toolButton(tool)} {/each} </div> <div class="flex shrink-0 items-center gap-xs px-xs"> <label for={previewId} class="flex items-center gap-1.5 typography-paragraph-regular" > <Eye class="size-3.5 shrink-0" aria-hidden="true" /> Preview </label> <Toggle id={previewId} bind:checked={preview} disabled={disabled || (!preview && !hasContent)} /> </div> </div> {/if}
<div class={classes.body()}> <Textarea id={textareaId} {name} rows={compact ? 1 : rows} size={compact ? "unsized" : "regular"} resizeable={!compact} {placeholder} bind:value bind:element={textareaEl} {disabled} aria-autocomplete="list" aria-controls={listId} aria-activedescendant={typeahead?.activeDescendant()} onkeydown={handleKeydown} onkeyup={handleKeyup} oninput={() => typeahead?.refresh()} onclick={() => typeahead?.refresh()} onscroll={() => typeahead?.refresh()} onblur={() => typeahead?.close()} class={classes.field()} /> {#if trailing} <div class="shrink-0"> {@render trailing()} </div> {/if} {#if preview} <div bind:this={pane} onscroll={handlePreviewScroll} class="absolute inset-0 flex flex-col overflow-y-auto" > <Pending> {#snippet failed()} <div class="px-2.5 py-xs typography-paragraph-small text-foreground-subtle italic" data-preview-ready > Could not render preview. </div> {/snippet} {@const html = await rendered} <div class="markup px-2.5 py-xs" data-preview-ready use:enhanceCodeblocks={html} {@attach alignPreview} > <!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in $lib/markup --> {@html html} </div> </Pending> </div> {/if} </div></div>
<MarkdownTypeahead bind:this={typeahead} textarea={textareaEl} {listId} />