Editor
A small in-page rich-text editor with Svelte: contenteditable, formatting commands, custom keymap, and a toolbar bound to the active range. Useful for comments, notes, and any inline editing surface where TipTap / ProseMirror is overkill.
Minimal contenteditable rich-text editor
EXAMPLE
<!-- Editor.svelte -->
<script lang='ts'>
import { onMount } from 'svelte';
export let value: string = '<p>Type here...</p>';
let editor: HTMLElement;
let isBold = false;
let isItalic = false;
let isLink = false;
let canUndo = false;
let canRedo = false;
function updateState() {
isBold = document.queryCommandState('bold');
isItalic = document.queryCommandState('italic');
isLink = !!document.queryCommandValue('createLink');
canUndo = document.queryCommandEnabled('undo');
canRedo = document.queryCommandEnabled('redo');
}
function exec(cmd: string, arg?: string) {
editor.focus();
document.execCommand(cmd, false, arg);
updateState();
value = editor.innerHTML;
}
function onInput() { value = editor.innerHTML; updateState(); }
function onKey(e: KeyboardEvent) {
if (e.metaKey || e.ctrlKey) {
const k = e.key.toLowerCase();
if (k === 'b') { e.preventDefault(); exec('bold'); }
if (k === 'i') { e.preventDefault(); exec('italic'); }
if (k === 'u') { e.preventDefault(); exec('underline'); }
if (k === 'k') { e.preventDefault(); promptLink(); }
}
}
function promptLink() {
const url = prompt('Link URL');
if (!url) return;
if (!/^https?:\/\//.test(url)) { alert('http(s) only'); return; }
exec('createLink', url);
}
function pasteAsPlainText(e: ClipboardEvent) {
e.preventDefault();
const text = (e.clipboardData || (window as any).clipboardData).getData('text/plain');
document.execCommand('insertText', false, text);
}
onMount(() => {
editor.innerHTML = value;
document.addEventListener('selectionchange', updateState);
return () => document.removeEventListener('selectionchange', updateState);
});
</script>
<div class='editor'>
<div class='toolbar' role='toolbar' aria-label='Format'>
<button on:click={() => exec('bold')} class:active={isBold} aria-pressed={isBold}>B</button>
<button on:click={() => exec('italic')} class:active={isItalic} aria-pressed={isItalic}><i>I</i></button>
<button on:click={() => exec('underline')}>U</button>
<button on:click={() => exec('insertUnorderedList')}>• list</button>
<button on:click={() => exec('insertOrderedList')}>1. list</button>
<button on:click={promptLink} class:active={isLink}>link</button>
<button on:click={() => exec('formatBlock', '<blockquote>')}>quote</button>
<button on:click={() => exec('removeFormat')}>clear</button>
<span class='spacer'></span>
<button on:click={() => exec('undo')} disabled={!canUndo}>undo</button>
<button on:click={() => exec('redo')} disabled={!canRedo}>redo</button>
</div>
<div
bind:this={editor}
role='textbox'
aria-multiline='true'
contenteditable='true'
on:input={onInput}
on:keydown={onKey}
on:paste={pasteAsPlainText}
class='content prose max-w-none focus:outline-none'
></div>
</div>
<style>
.editor { border: 1px solid #ddd; border-radius: 8px; }
.toolbar {
display: flex; gap: 4px; padding: .5rem; border-bottom: 1px solid #eee;
background: #fafafa;
}
.toolbar button {
padding: .25rem .5rem; border: 0; background: transparent; cursor: pointer;
border-radius: 4px;
}
.toolbar button:hover { background: #eee; }
.toolbar button.active { background: #e0e7ff; color: #1d4ed8; font-weight: 600; }
.toolbar button:disabled { opacity: .4; cursor: not-allowed; }
.toolbar .spacer { flex: 1; }
.content { padding: 1rem; min-height: 12rem; }
</style>
<!-- ===== Notes =====
- document.execCommand is deprecated but still works in every browser.
- For complex needs (collaboration, schemas, plugins), use ProseMirror / TipTap.
- ALWAYS sanitise the resulting HTML server-side (DOMPurify / HTMLPurifier) before
storing or rendering it as user content; this editor itself is not a sanitiser.
- Strip foreign formatting on paste (the on:paste handler above does this).
- Bind 'value' to a Svelte writable to integrate with reactive forms.
-->
Why it matters
`document.execCommand` is deprecated but is still the smallest path to a working WYSIWYG. The serious editors (TipTap, Slate, Lexical, ProseMirror) trade a much bigger bundle for collaboration, schema validation, and plugins. For comment boxes and notes, this 80-line component is enough — and sanitising the output server-side is non-negotiable either way.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Discussion
Loading…