Something went wrong. Try again.
Monorepo for Tangled forked from tangled.org/core
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676<script module lang="ts"> import { tv, type VariantProps } from "tailwind-variants"; import { layerCard } from "#lib/components/ui/LayerCard.svelte"; import { layerCardPrimary } from "#lib/components/ui/LayerCardPrimary.svelte";
export const markdownEditor = tv({ slots: { root: "flex flex-col", toolbar: "order-first 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 bg-transparent inset-ring-0 focus-within:ring-0 forced-colors:outline-none" }, 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" } }, layered: { true: { root: layerCard(), toolbar: "border-b-0 bg-transparent p-px", body: layerCardPrimary({ padding: "none" }) }, false: "" }, disabled: { true: "", false: "" } }, compoundVariants: [ { compact: false, layered: false, class: { root: "rounded-md edge-ring" } }, { compact: false, disabled: true, class: { root: "cursor-not-allowed ring-border-disabled" } } ], defaultVariants: { transparent: false, compact: false, layered: 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 { ClipboardEventHandler, DragEventHandler, 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 Image from "$icon/image"; 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 MoreMenu from "#lib/components/ui/MoreMenu.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, pasteLink, separator, uploadedImage, table, wrap, type Edit, type ListKind, type Selection } from "#lib/components/ui/markdownFormat.ts"; import { editorLine, previewLine, scrollEditorToLine, scrollPreviewToLine } from "#lib/components/ui/markdownScroll.ts"; import { blobLink, loadMarkup, mediaUrl, renderPreview, type MarkupContext } from "#lib/markup/index.ts"; import { blobCidOf, type PendingImage } from "#lib/api/markupBlobs.ts"; import { ImageError, prepareImage } from "#lib/images.ts"; import ErrorAlert from "#lib/components/ui/Error.svelte"; import { codeblockToolbars } from "#lib/components/ui/codeblockToolbars.ts"; import { mermaidDiagrams } from "#lib/components/ui/mermaidDiagrams.ts"; import { isMac, keyLabel } from "#lib/components/ui/keyLabels.ts";
interface Tool { label: string; icon: Component<SvelteHTMLElements["svg"]>; format: (selection: Selection) => Edit; key?: string; shift?: boolean; pick?: 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: Image, format: image, pick: true } ];
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 } ];
const TOOLBAR: { label: string; tool?: Tool }[] = [ ...INLINE_TOOLS.map((tool) => ({ label: tool.label, tool })), { label: "List" }, ...BLOCK_TOOLS.map((tool) => ({ label: tool.label, tool })) ];
const PRIORITY = [ "Image", "Bold", "Italic", "Strikethrough", "Code", "Link", "List", "Code block", "Table", "Separator" ];
interface Props { value?: string; markup: MarkupContext; preview?: boolean; toolbar?: boolean; id?: string; name?: string; rows?: number; placeholder?: string; disabled?: boolean; autofocus?: boolean; transparent?: boolean; compact?: boolean; fit?: boolean; layered?: boolean; trailing?: Snippet; testid?: string; // the did that will own the record, dropping images only works where it's set owner?: string; // pending images, uploaded to owner when the record is saved pending?: PendingImage[]; }
let { value = $bindable(""), markup, preview = $bindable(false), toolbar = true, id, name, rows = 3, placeholder, disabled = false, autofocus = false, transparent = false, compact = false, fit = false, layered = false, trailing, testid, owner, pending = $bindable([]) }: 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, layered, 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); let toolRow = $state<HTMLElement>(); let moreEl = $state<HTMLElement>(); const toolEls: Record<string, HTMLElement> = $state({}); const toolWidths: Record<string, number> = {}; let moreWidth = 0; let visibleCount = $state(PRIORITY.length); const shown = (label: string) => PRIORITY.indexOf(label) < visibleCount; const overflowed = $derived(TOOLBAR.filter((item) => !shown(item.label)));
const fitToolbar = () => { const row = toolRow; if (!row) return; for (const label of PRIORITY) { const width = toolEls[label]?.getBoundingClientRect().width ?? 0; if (width > 0) toolWidths[label] = width; } const measuredMore = moreEl?.getBoundingClientRect().width ?? 0; if (measuredMore > 0) moreWidth = measuredMore; if (PRIORITY.some((label) => !toolWidths[label])) return; const gap = parseFloat(getComputedStyle(row).columnGap) || 0; const available = row.clientWidth; const all = PRIORITY.reduce((sum, label) => sum + toolWidths[label] + gap, -gap); if (all <= available) { visibleCount = PRIORITY.length; return; } const budget = available - (moreWidth || toolWidths.Bold) - gap; let used = -gap; let count = 0; for (const label of PRIORITY) { if (used + gap + toolWidths[label] > budget) break; used += gap + toolWidths[label]; count++; } visibleCount = count; };
$effect(() => { const row = toolRow; if (!row) return; const observer = new ResizeObserver(fitToolbar); observer.observe(row); return () => observer.disconnect(); });
onMount(() => { mac = isMac(); return () => objectUrls.forEach((url) => URL.revokeObjectURL(url)); });
$effect(() => { if (autofocus) textareaEl?.focus(); });
$effect(() => { const el = textareaEl; if (!(compact || fit) || !el) return; void value; el.style.height = "auto"; el.style.height = `${el.scrollHeight}px`; });
const hasContent = $derived(value.trim() !== "");
const rendered = $derived( // same options Markup uses for a posted comment preview && hasContent ? renderPreview(value, { ...markup, sourceLines: true, collapseCode: true }) : null );
const shortcutKeys = (tool: Tool): string[] => tool.key ? [ keyLabel("mod", mac), ...(tool.shift ? [keyLabel("shift", mac)] : []), tool.key.toUpperCase() ] : [];
const ariaShortcut = (tool: Tool) => tool.key ? [ mac ? "Meta" : "Control", ...(tool.shift ? ["Shift"] : []), tool.key.toUpperCase() ].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; } if (mac ? event.ctrlKey : event.metaKey) 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); };
const handlePaste: ClipboardEventHandler<HTMLTextAreaElement> = (event) => { const { value: text, selectionStart: start, selectionEnd: end } = event.currentTarget; const pasted = [...(event.clipboardData?.files ?? [])].filter( (file) => owner && file.type.startsWith("image/") && file.size > 0 ); if (pasted.length > 0) { event.preventDefault(); void addImages(pasted); return; } const edit = pasteLink( { value: text, start, end }, event.clipboardData?.getData("text/plain") ?? "" ); if (!edit) return; event.preventDefault(); applyEdit(event.currentTarget, edit); };
const objectUrls: string[] = []; let imageError = $state<string>(); let fileInput = $state<HTMLInputElement>();
const addImage = async (file: File) => { if (!owner) return; const prepared = await prepareImage(file); const cid = await blobCidOf(prepared); const url = URL.createObjectURL(prepared); objectUrls.push(url); pending = [...pending, { cid, file: prepared, url }]; apply((s) => uploadedImage(s, blobLink(owner, cid))); };
const addImages = async (files: File[]) => { imageError = undefined; for (const file of files) { try { await addImage(file); } catch (error) { imageError = error instanceof ImageError ? error.message : `"${file.name}" couldn't be added.`; } } };
const activate = (tool: Tool) => { if (tool.pick && owner) fileInput?.click(); else apply(tool.format); };
const handleFiles = (event: Event & { currentTarget: HTMLInputElement }) => { const files = [...(event.currentTarget.files ?? [])]; event.currentTarget.value = ""; void addImages(files); };
// a dropped image isn't served until a saved record references it, so preview shows the local copy const localImages = (html: string | null): Attachment<HTMLElement> => (node) => { if (!owner || !html) return; for (const image of pending) { const src = mediaUrl(blobLink(owner, image.cid), markup); for (const img of node.querySelectorAll("img")) if (img.getAttribute("src") === src) img.src = image.url; } };
const handleDragover: DragEventHandler<HTMLTextAreaElement> = (event) => { if (event.dataTransfer?.types.includes("Files")) event.preventDefault(); };
const handleDrop: DragEventHandler<HTMLTextAreaElement> = (event) => { const images = [...(event.dataTransfer?.files ?? [])].filter( (file) => file.type.startsWith("image/") && file.size > 0 ); if (images.length === 0) return; event.preventDefault(); void addImages(images); };
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 listItems()} {#each LISTS as entry (entry.kind)} <DropdownItem icon={entry.icon} onclick={() => apply((s) => list(s, entry.kind))}> {entry.label} </DropdownItem> {/each}{/snippet}
{#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={() => activate(tool)} class="hit-area-x-1.5 overflow-visible" /> </Tooltip>{/snippet}
<div class={classes.root()}> <div class={classes.body()}> <Textarea id={textareaId} data-testid={testid} {name} rows={compact || fit ? 1 : rows} size={compact || fit ? "unsized" : "regular"} resizeable={!compact && !fit} {placeholder} bind:value bind:element={textareaEl} {disabled} aria-autocomplete="list" aria-controls={listId} aria-activedescendant={typeahead?.activeDescendant()} onkeydown={handleKeydown} onkeyup={handleKeyup} onpaste={handlePaste} ondragover={owner ? handleDragover : undefined} ondrop={owner ? handleDrop : undefined} oninput={() => typeahead?.refresh()} onclick={() => typeahead?.refresh()} onscroll={() => typeahead?.refresh()} onfocus={() => void loadMarkup().catch(() => {})} 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:codeblockToolbars={html} use:mermaidDiagrams={html} {@attach alignPreview} {@attach localImages(html)} > <!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in $lib/markup --> {@html html} </div> </Pending> </div> {/if} </div>
{#if !compact && toolbar} <div class={classes.toolbar()}> <div bind:this={toolRow} role="toolbar" aria-label="Formatting" aria-controls={textareaId} class="flex min-w-0 flex-1 items-center gap-xs overflow-x-clip [overflow-clip-margin:var(--spacing-2xs)]" > {#each TOOLBAR as item (item.label)} <div bind:this={toolEls[item.label]} class={shown(item.label) ? "flex" : "hidden"} > {#if item.tool} {@render toolButton(item.tool)} {:else} <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} {@render listItems()} </Dropdown> </Tooltip> {/if} </div> {/each} {#if overflowed.length > 0} <div bind:this={moreEl} class="flex"> <MoreMenu label="More formatting" size="mini" align="left" disabled={disabled || preview} > {#each overflowed as item (item.label)} {#if item.tool} {@const tool = item.tool} <DropdownItem icon={tool.icon} onclick={() => activate(tool)}> {tool.label} </DropdownItem> {:else} {@render listItems()} {/if} {/each} </MoreMenu> </div> {/if} </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" /> <span class="max-sm:sr-only">Preview</span> </label> <Toggle id={previewId} bind:checked={preview} disabled={disabled || (!preview && !hasContent)} /> </div> </div> {/if}</div>
{#if owner} <input type="file" accept="image/*" multiple hidden bind:this={fileInput} onchange={handleFiles} /> <ErrorAlert label={imageError} class="mt-xs" />{/if}
<MarkdownTypeahead bind:this={typeahead} textarea={textareaEl} {listId} />