MarkdownEditor
A Markdown textarea with a toolbar that wraps the selection (bold, italic, link, list) and a Preview tab rendered as the page will show it.
Demo
Nothing to preview.
Ctrl+B and Ctrl+I work too.
Nothing to preview.
19 characters of Markdown.
Get it
It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.
$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/markdown_editor.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/markdown_editor.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/markdown_editor.qwe
component MarkdownEditorDemoLive() { state text = "Some **bold** text." <div.demo-stack> <MarkdownEditor name="md-demo-note" label="Note" rows={4} value={text} tools={["bold", "italic", "link", "code"]} on_change={(t) => { text = t }}/> <p.demo-note>{len(text)} characters of Markdown.</p> </div>}view MarkdownEditorDemo() { <div.demo-stack> <MarkdownEditor name="md-demo-post" label="Post" placeholder="Write in Markdown…" value={"# Release notes\n\nThe gallery has **search** now.\n\n- Tags\n- Dark mode"} hint="Ctrl+B and Ctrl+I work too."/> <MarkdownEditorDemoLive/> </div>}Source
ui/markdown_editor.qwe
// MarkdownEditor: a box for Markdown with a toolbar (bold, italic, heading, link,// list, quote, code) that wraps what is selected, and a Preview tab that shows the// text rendered, by Quillo's own `markdown()` (so the preview is what the page// will show). It is a real <textarea> named `name`, so a plain <form> sends the// Markdown; the route renders it with `markdown(text)`.//// <MarkdownEditor name="body" label="Post" rows={10} value={post.body}/>// <MarkdownEditor name="note" placeholder="Write in Markdown…" on_change={(t) => { note = t }}/>//// value: the Markdown to start with. rows: the box's height in lines (8).// tools: which buttons, in order, from "bold", "italic", "heading", "link",// "list", "numbered", "quote", "code" (all by default). preview: whether to offer// the Preview tab (true). Ctrl/Cmd+B and Ctrl/Cmd+I make bold and italic.// name, label, placeholder, hint, error, required, disabled and field (the box's// id, "markdown-editor-" + name by default) as for every field.// on_change(text): inside one of your components, given the Markdown as it is typed.import ui/theme.qwe use ui_clslet markdown_editor_css = css`.ui-markdown-editor { display: grid; gap: 6px; min-width: 0; font: 14px/1.4 var(--ui-font); color: var(--ui-fg); }.ui-markdown-editor-label { font-size: 13px; font-weight: 500; }.ui-markdown-editor-req { margin-left: 2px; color: var(--ui-danger); }.ui-markdown-editor-frame { border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); overflow: hidden; transition: border-color .15s, box-shadow .15s; }.ui-markdown-editor-frame:focus-within { border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-markdown-editor-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 2px; padding: 4px 6px; border-bottom: 1px solid var(--ui-border); background: var(--ui-subtle); }.ui-markdown-editor-tabs { display: flex; gap: 2px; margin-right: 8px; }.ui-markdown-editor-tab { height: 28px; padding: 0 10px; border: 0; border-radius: var(--ui-radius-sm); background: transparent; color: var(--ui-muted); font: 500 13px/1 var(--ui-font); cursor: pointer;}.ui-markdown-editor-tab[aria-selected="true"] { background: var(--ui-surface); color: var(--ui-fg); box-shadow: 0 0 0 1px var(--ui-border); }.ui-markdown-editor-tab:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-markdown-editor-tool { display: inline-grid; place-items: center; width: 28px; height: 28px; padding: 0; border: 0; border-radius: var(--ui-radius-sm); background: transparent; color: var(--ui-fg); font: 600 13px/1 var(--ui-font); cursor: pointer;}.ui-markdown-editor-tool:hover { background: var(--ui-surface); box-shadow: 0 0 0 1px var(--ui-border); }.ui-markdown-editor-tool:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-markdown-editor-tool:disabled { opacity: .45; cursor: not-allowed; }.ui-markdown-editor-tool svg { width: 16px; height: 16px; }.ui-markdown-editor-tool i { font-style: italic; font-family: Georgia, serif; font-size: 15px; }.ui-markdown-editor-frame textarea { display: block; box-sizing: border-box; width: 100%; min-height: 80px; margin: 0; padding: 10px 12px; border: 0; outline: none; resize: vertical; background: transparent; color: var(--ui-fg); font: 14px/1.6 var(--ui-mono);}.ui-markdown-editor-frame textarea::placeholder { color: var(--ui-muted); opacity: 1; }.ui-markdown-editor-frame textarea:disabled { opacity: .55; cursor: not-allowed; }.ui-markdown-editor-preview { min-height: 80px; padding: 10px 14px; line-height: 1.6; overflow-wrap: anywhere; }.ui-markdown-editor-preview > :first-child { margin-top: 0; }.ui-markdown-editor-preview > :last-child { margin-bottom: 0; }.ui-markdown-editor-preview h1, .ui-markdown-editor-preview h2, .ui-markdown-editor-preview h3 { margin: .8em 0 .4em; line-height: 1.25; }.ui-markdown-editor-preview pre { padding: 10px 12px; border-radius: var(--ui-radius-sm); background: var(--ui-subtle); overflow: auto; font: 13px/1.5 var(--ui-mono); }.ui-markdown-editor-preview code { font: 13px var(--ui-mono); }.ui-markdown-editor-preview blockquote { margin: .6em 0; padding: 0 0 0 12px; border-left: 3px solid var(--ui-border); color: var(--ui-muted); }.ui-markdown-editor-preview a { color: var(--ui-accent); }.ui-markdown-editor-empty { color: var(--ui-muted); font-style: italic; }.ui-markdown-editor-invalid .ui-markdown-editor-frame { border-color: var(--ui-danger); }.ui-markdown-editor-hint, .ui-markdown-editor-error { margin: 0; font-size: 13px; color: var(--ui-muted); }.ui-markdown-editor-error { color: var(--ui-danger); }@media (prefers-reduced-motion: reduce) { .ui-markdown-editor-frame { transition: none; } }`let markdown_editor_tools = ["bold", "italic", "heading", "link", "list", "numbered", "quote", "code"]// `text` with the selection (from a to b) wrapped or its lines prefixed for the// tool: { text, start, end } with where the selection goes after.fn markdown_editor_apply(tool: str, text: str, a: int, b: int) { let before = slice(text, 0, a) let sel = slice(text, a, b) let after = slice(text, b) let wrap = (open: str, close: str, blank: str) => { let body = sel == "" ? blank : sel { text: before + open + body + close + after, start: a + len(open), end: a + len(open) + len(body) } } let lines = (mark) => { // The whole lines the selection touches, each given its mark. let from = (index_of(reverse(split(before, "")), "\n") ?? len(before)) let line_start = len(before) - from let to_nl = index_of(split(after, ""), "\n") let line_end = to_nl == null ? len(text) : b + to_nl let block = slice(text, line_start, line_end) let marked = split(block, "\n") | enumerate | map((p) => mark(p[0]) + p[1]) | join("\n") { text: slice(text, 0, line_start) + marked + slice(text, line_end), start: line_start, end: line_start + len(marked) } } match tool { "bold" => wrap("**", "**", "bold text") "italic" => wrap("_", "_", "italic text") "code" => contains(sel, "\n") ? wrap("```\n", "\n```", "code") : wrap("`", "`", "code") "link" => { let body = sel == "" ? "link text" : sel { text: before + "[" + body + "](https://)" + after, start: a + len(body) + 3, end: a + len(body) + 11 } } "heading" => lines((i) => "## ") "list" => lines((i) => "- ") "numbered" => lines((i) => `${i + 1}. `) "quote" => lines((i) => "> ") _ => { text, start: a, end: b } }}component MarkdownEditor(name: str? = null, label: str? = null, value: str = "", rows: int = 8, tools: list? = null, preview: bool = true, placeholder: str? = null, hint: str? = null, error: str? = null, required: bool = false, disabled: bool = false, field: str? = null, on_change = null, class: str? = null) { style global markdown_editor_css state text = value state tab = "write" state html = "" state rendering = false let id = field ?? `markdown-editor-${name ?? re.replace(lower(label ?? "text"), "[^a-z0-9]+", "-")}` let described = ui_cls([hint != null && `${id}-hint`, error != null && `${id}-error`]) let offered = (tools ?? markdown_editor_tools) | filter((t) => contains(markdown_editor_tools, t)) let apply = (e, tool: str) => { let box = e.target.closest(".ui-markdown-editor").querySelector("textarea") let r = markdown_editor_apply(tool, text, box.selectionStart, box.selectionEnd) text = r.text box.value = r.text box.focus() box.setSelectionRange(r.start, r.end) on_change && on_change(r.text) } let show_preview = () => { tab = "preview" rendering = true html = trim(text) == "" ? "" : str(markdown(text)) rendering = false } let icon = (tool: str) => match tool { "bold" => <span aria-hidden="true">B</span> "italic" => <i aria-hidden="true">I</i> "heading" => <span aria-hidden="true">H</span> "link" => <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><path d="M6.5 9.5l3-3M7 11l-1.5 1.5a2.1 2.1 0 0 1-3-3L4 8M9 5l1.5-1.5a2.1 2.1 0 0 1 3 3L12 8"/></svg> "list" => <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><path d="M6 4h7M6 8h7M6 12h7"/><circle cx="3" cy="4" r=".8" fill="currentColor"/><circle cx="3" cy="8" r=".8" fill="currentColor"/><circle cx="3" cy="12" r=".8" fill="currentColor"/></svg> "numbered" => <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><path d="M7 4h6M7 8h6M7 12h6M2.5 3l1-.6V6M2.3 9.5c.3-.6 1.4-.8 1.8 0 .3.7-1.6 1.6-1.8 2.5h2"/></svg> "quote" => <svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M3 9.5a2.5 2.5 0 1 0 2.5-2.5h-.3C5.5 5.6 6.3 4.6 7.5 4V2.5C5 3.2 3 5.3 3 8.6zM9 9.5a2.5 2.5 0 1 0 2.5-2.5h-.3c.3-1.4 1.1-2.4 2.3-3V2.5C11 3.2 9 5.3 9 8.6z"/></svg> _ => <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5.5 4L2 8l3.5 4M10.5 4L14 8l-3.5 4"/></svg> } let title = (tool: str) => match tool { "bold" => "Bold (Ctrl+B)" "italic" => "Italic (Ctrl+I)" "heading" => "Heading" "link" => "Link" "list" => "Bulleted list" "numbered" => "Numbered list" "quote" => "Quote" _ => "Code" } <div class={ui_cls(["ui-markdown-editor", error != null && "ui-markdown-editor-invalid", class])}> {label != null && <label.ui-markdown-editor-label for={id}> {label}{required && <span.ui-markdown-editor-req aria-hidden="true">*</span>} </label>} <div.ui-markdown-editor-frame> <div.ui-markdown-editor-bar> {preview && <div.ui-markdown-editor-tabs role="tablist" aria-label="Write or preview"> <button.ui-markdown-editor-tab type="button" role="tab" id={`${id}-tab-write`} aria-selected={tab == "write" ? "true" : "false"} aria-controls={`${id}-write`} @click={() => { tab = "write" }}>Write</button> <button.ui-markdown-editor-tab type="button" role="tab" id={`${id}-tab-preview`} aria-selected={tab == "preview" ? "true" : "false"} aria-controls={`${id}-preview`} @click={() => { show_preview() }}>Preview</button> </div>} {for t in offered => <button.ui-markdown-editor-tool type="button" key={t} title={title(t)} aria-label={title(t)} disabled={disabled || tab != "write"} @click={(e) => apply(e, t)}>{icon(t)}</button>} </div> <div id={`${id}-write`} role={preview ? "tabpanel" : null} aria-labelledby={preview ? `${id}-tab-write` : null} hidden={tab != "write"}> <textarea id={id} name={name} value={text} rows={rows} placeholder={placeholder} required={required} disabled={disabled} spellcheck="true" aria-invalid={error != null ? "true" : null} aria-describedby={described != "" ? described : null} @input={(e) => { text = e.value on_change && on_change(e.value) }} @keydown={(e) => { if (e.ctrlKey || e.metaKey) && (lower(e.key) == "b" || lower(e.key) == "i") { e.preventDefault() apply(e, lower(e.key) == "b" ? "bold" : "italic") } }}></textarea> </div> {preview && <div.ui-markdown-editor-preview id={`${id}-preview`} role="tabpanel" aria-labelledby={`${id}-tab-preview`} aria-busy={rendering ? "true" : null} hidden={tab != "preview"}> {html == "" ? <p.ui-markdown-editor-empty>{rendering ? "Rendering…" : "Nothing to preview."}</p> : raw(html)} </div>} </div> {hint != null && <p.ui-markdown-editor-hint id={`${id}-hint`}>{hint}</p>} {error != null && <p.ui-markdown-editor-error id={`${id}-error`} role="alert">{error}</p>} </div>}Related
CharacterCounterA text box or textarea with a live 120 / 280 count that warns near the limit, as a hard stop or a soft one that blocks sending.5 views · 6 downloadsInlineEditText that turns into a box to change it in place: Enter saves, Escape puts it back.5 views · 7 downloadsMaskedInputA box that shapes what is typed to a pattern (### ###-####, AA-00-AA) as it is typed, dropping what does not fit.6 views · 8 downloadsRichTextEditorA formatted-text editor (bold, italic, lists, links, headings) on contenteditable, its HTML kept in a hidden input a form sends.7 views · 5 downloadsTextAreaA box for several lines, with a live count against a limit and an option to grow with what is typed.5 views · 8 downloadsTextFieldA labelled input with help text, an error message and short text before or after it.5 views · 8 downloads
Thread
Thread 0 posts
On MarkdownEditor: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.