iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

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.

Example

Example
<!-- The Svelte playground uses the compiler in-browser. -->
Try it Yourself »

Discussion

Loading…