{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "rich-text-editor",
  "title": "Rich Text Editor",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-svelte/components/rich-text-editor/RichTextEditor.svelte",
      "content": "<script lang=\"ts\" module>\n  import type { HTMLAttributes } from 'svelte/elements'\n\n  export interface RichTextEditorProps extends HTMLAttributes<HTMLDivElement> {\n    value?: string\n    placeholder?: string\n    editorClass?: string\n    minHeight?: string\n    onValueChange?: (html: string) => void\n    ref?: HTMLDivElement | null\n  }\n</script>\n\n<script lang=\"ts\">\n  import { Editor } from '@tiptap/core'\n  import StarterKit from '@tiptap/starter-kit'\n  import Placeholder from '@tiptap/extension-placeholder'\n  import Underline from '@tiptap/extension-underline'\n  import Link from '@tiptap/extension-link'\n  import TextAlign from '@tiptap/extension-text-align'\n  import TaskList from '@tiptap/extension-task-list'\n  import TaskItem from '@tiptap/extension-task-item'\n  import {\n    Bold,\n    Italic,\n    Underline as UnderlineIcon,\n    Strikethrough,\n    List,\n    ListOrdered,\n    ListChecks,\n    AlignLeft,\n    AlignCenter,\n    AlignRight,\n    Link as LinkIcon,\n    Heading1,\n    Heading2,\n    Quote,\n    Minus,\n    Undo2,\n    Redo2,\n    Code,\n    RemoveFormatting,\n    ChevronDown,\n  } from '@lucide/svelte'\n  import { untrack } from 'svelte'\n  import { Toggle } from '$lib/components/ui/toggle'\n  import { Separator } from '$lib/components/ui/separator'\n  import { Button } from '$lib/components/ui/button'\n  import { Input } from '$lib/components/ui/input'\n  import { cn } from '$lib/utils'\n\n  let {\n    class: className,\n    value = $bindable(''),\n    placeholder = 'Start writing...',\n    editorClass,\n    minHeight = '120px',\n    onValueChange,\n    ref = $bindable(null),\n    ...restProps\n  }: RichTextEditorProps = $props()\n\n  let element: HTMLDivElement | null = $state(null)\n  let editor = $state<Editor | null>(null)\n  // Bumped on every transaction so toolbar active-states re-derive.\n  let tick = $state(0)\n\n  $effect(() => {\n    if (!element) return\n    // untrack mount-time content/placeholder: onUpdate writes `value` below,\n    // which would otherwise re-trigger this effect and recreate the editor on\n    // every keystroke (destroy/create loop → effect_update_depth_exceeded).\n    // External updates still flow through the controlled effect underneath.\n    const initialContent = untrack(() => value)\n    const initialPlaceholder = untrack(() => placeholder)\n    const e: Editor = new Editor({\n      element,\n      content: initialContent,\n      extensions: [\n        StarterKit.configure({\n          heading: { levels: [1, 2, 3] },\n          // StarterKit ships its own link + underline since v3 — disable them so\n          // we can register the standalone packages with our own configuration\n          // without TipTap warning about duplicate extension names.\n          link: false,\n          underline: false,\n        }),\n        Placeholder.configure({ placeholder: initialPlaceholder }),\n        Underline,\n        Link.configure({\n          openOnClick: false,\n          HTMLAttributes: { class: 'text-primary underline cursor-pointer' },\n        }),\n        TextAlign.configure({ types: ['heading', 'paragraph'] }),\n        TaskList,\n        TaskItem.configure({ nested: true }),\n      ],\n      editorProps: {\n        attributes: {\n          class: 'prose prose-sm dark:prose-invert max-w-none focus:outline-none',\n        },\n      },\n      onUpdate: ({ editor: updated }: any) => {\n        const html = updated.getHTML()\n        value = html\n        onValueChange?.(html)\n      },\n    })\n    editor = e\n    lastSyncedHtml = null\n    const bump = () => tick++\n    e.on('transaction', bump)\n    return () => {\n      e.off('transaction', bump)\n      e.destroy()\n      editor = null\n    }\n  })\n\n  // Controlled updates from the parent.\n  // Guarded two ways: tiptap normalizes content ('' -> '<p></p>'), so a naive\n  // getHTML() comparison re-pushes on every run; each push dispatches a\n  // transaction that re-triggers this effect (effect_update_depth_exceeded).\n  let lastSyncedHtml: string | null = null\n  $effect(() => {\n    const html = value ?? ''\n    if (editor && html !== lastSyncedHtml && editor.getHTML() !== html) {\n      lastSyncedHtml = html\n      editor.commands.setContent(html, { emitUpdate: false })\n    }\n  })\n\n  // Link editing lives in an inline panel rather than window.prompt: a native\n  // prompt is unstyleable, blocks the main thread, cannot be tested, and is\n  // suppressed outright in sandboxed iframes and some mobile browsers.\n  let linkOpen = $state(false)\n  let linkUrl = $state('')\n  let linkPanelEl: HTMLDivElement | null = $state(null)\n  const linkFieldId = `rte-link-${Math.random().toString(36).slice(2, 9)}`\n\n  function openLinkEditor() {\n    if (!editor) return\n    // Prefill with the current href so the panel edits instead of replaces.\n    linkUrl = (editor.getAttributes('link').href as string | undefined) ?? ''\n    linkOpen = true\n  }\n\n  function applyLink() {\n    const url = linkUrl.trim()\n    if (!editor || !url) return\n    editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run()\n    linkOpen = false\n  }\n\n  function removeLink() {\n    editor?.chain().focus().extendMarkRange('link').unsetLink().run()\n    linkUrl = ''\n    linkOpen = false\n  }\n\n  $effect(() => {\n    if (!linkOpen) return\n    linkPanelEl?.querySelector('input')?.focus()\n    const onDocPointerDown = (e: PointerEvent) => {\n      if (linkPanelEl && !linkPanelEl.contains(e.target as Node)) linkOpen = false\n    }\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key === 'Escape') linkOpen = false\n    }\n    document.addEventListener('pointerdown', onDocPointerDown, { capture: true })\n    document.addEventListener('keydown', onKey)\n    return () => {\n      document.removeEventListener('pointerdown', onDocPointerDown, { capture: true })\n      document.removeEventListener('keydown', onKey)\n    }\n  })\n\n  interface ToolbarItem {\n    type: 'button' | 'separator' | 'link'\n    icon?: any\n    action?: () => void\n    isActive?: () => boolean\n    title?: string\n  }\n\n  let showExtended = $state(false)\n\n  const essentialItems = $derived.by((): ToolbarItem[] => {\n    void tick\n    if (!editor) return []\n    const e = editor\n    return [\n      { type: 'button', icon: Bold, action: () => e.chain().focus().toggleBold().run(), isActive: () => e.isActive('bold'), title: 'Bold' },\n      { type: 'button', icon: Italic, action: () => e.chain().focus().toggleItalic().run(), isActive: () => e.isActive('italic'), title: 'Italic' },\n      { type: 'button', icon: UnderlineIcon, action: () => e.chain().focus().toggleUnderline().run(), isActive: () => e.isActive('underline'), title: 'Underline' },\n      { type: 'button', icon: Strikethrough, action: () => e.chain().focus().toggleStrike().run(), isActive: () => e.isActive('strike'), title: 'Strikethrough' },\n      { type: 'separator' },\n      { type: 'button', icon: List, action: () => e.chain().focus().toggleBulletList().run(), isActive: () => e.isActive('bulletList'), title: 'Bullet list' },\n      { type: 'button', icon: ListOrdered, action: () => e.chain().focus().toggleOrderedList().run(), isActive: () => e.isActive('orderedList'), title: 'Numbered list' },\n      { type: 'separator' },\n      { type: 'link', icon: LinkIcon, action: openLinkEditor, isActive: () => e.isActive('link'), title: 'Link' },\n      { type: 'separator' },\n      { type: 'button', icon: Undo2, action: () => e.chain().focus().undo().run(), isActive: () => false, title: 'Undo' },\n      { type: 'button', icon: Redo2, action: () => e.chain().focus().redo().run(), isActive: () => false, title: 'Redo' },\n    ]\n  })\n\n  const extendedItems = $derived.by((): ToolbarItem[] => {\n    void tick\n    if (!editor) return []\n    const e = editor\n    return [\n      { type: 'button', icon: Heading1, action: () => e.chain().focus().toggleHeading({ level: 1 }).run(), isActive: () => e.isActive('heading', { level: 1 }), title: 'Heading 1' },\n      { type: 'button', icon: Heading2, action: () => e.chain().focus().toggleHeading({ level: 2 }).run(), isActive: () => e.isActive('heading', { level: 2 }), title: 'Heading 2' },\n      { type: 'separator' },\n      { type: 'button', icon: Code, action: () => e.chain().focus().toggleCode().run(), isActive: () => e.isActive('code'), title: 'Inline code' },\n      { type: 'button', icon: Quote, action: () => e.chain().focus().toggleBlockquote().run(), isActive: () => e.isActive('blockquote'), title: 'Blockquote' },\n      { type: 'button', icon: Minus, action: () => e.chain().focus().setHorizontalRule().run(), isActive: () => false, title: 'Divider' },\n      { type: 'button', icon: ListChecks, action: () => e.chain().focus().toggleTaskList().run(), isActive: () => e.isActive('taskList'), title: 'Task list' },\n      { type: 'separator' },\n      { type: 'button', icon: AlignLeft, action: () => e.chain().focus().setTextAlign('left').run(), isActive: () => e.isActive({ textAlign: 'left' }), title: 'Align left' },\n      { type: 'button', icon: AlignCenter, action: () => e.chain().focus().setTextAlign('center').run(), isActive: () => e.isActive({ textAlign: 'center' }), title: 'Align center' },\n      { type: 'button', icon: AlignRight, action: () => e.chain().focus().setTextAlign('right').run(), isActive: () => e.isActive({ textAlign: 'right' }), title: 'Align right' },\n      { type: 'separator' },\n      { type: 'button', icon: RemoveFormatting, action: () => e.chain().focus().clearNodes().unsetAllMarks().run(), isActive: () => false, title: 'Clear formatting' },\n    ]\n  })\n\n  const linkActive = $derived.by(() => {\n    void tick\n    return editor?.isActive('link') ?? false\n  })\n</script>\n\n<div bind:this={ref} data-uipkge data-slot=\"rich-text-editor\" class={cn('rich-text-editor rounded-lg border', className)} {...restProps}>\n  <!-- Toolbar -->\n  {#if editor}\n    <div class=\"border-b\" role=\"toolbar\" aria-label=\"Text formatting\">\n      <!-- Essential row -->\n      <div class=\"relative flex items-center gap-0.5 px-2 py-1.5\">\n        {#each essentialItems as item, i (i)}\n          {#if item.type === 'separator'}\n            <Separator orientation=\"vertical\" class=\"mx-1 h-5\" />\n          {:else if item.type === 'link'}\n            {@const LinkItemIcon = item.icon}\n            <Toggle\n              size=\"sm\"\n              pressed={item.isActive?.()}\n              title={item.title}\n              aria-label={item.title}\n              class=\"focus-visible:ring-ring size-7 p-0 focus-visible:ring-2 focus-visible:outline-none\"\n              onclick={openLinkEditor}\n            >\n              <LinkItemIcon class=\"size-3.5\" aria-hidden=\"true\" />\n            </Toggle>\n          {:else}\n            {@const ItemIcon = item.icon}\n            <Toggle\n              size=\"sm\"\n              pressed={item.isActive?.()}\n              title={item.title}\n              aria-label={item.title}\n              class=\"focus-visible:ring-ring size-7 p-0 focus-visible:ring-2 focus-visible:outline-none\"\n              onclick={() => item.action?.()}\n            >\n              <ItemIcon class=\"size-3.5\" aria-hidden=\"true\" />\n            </Toggle>\n          {/if}\n        {/each}\n\n        <Separator orientation=\"vertical\" class=\"mx-1 h-5\" />\n\n        <!-- Expand toggle -->\n        <button\n          type=\"button\"\n          title={showExtended ? 'Hide more options' : 'Show more options'}\n          aria-label={showExtended ? 'Hide more options' : 'Show more options'}\n          aria-expanded={showExtended}\n          class={cn(\n            'text-muted-foreground hover:text-foreground hover:bg-muted focus-visible:ring-ring inline-flex size-7 items-center justify-center rounded-md transition-colors focus-visible:ring-2 focus-visible:outline-none',\n            showExtended && 'bg-muted text-foreground',\n          )}\n          onclick={() => (showExtended = !showExtended)}\n        >\n          <ChevronDown\n            class={cn('size-3.5 transition-transform duration-200', showExtended && 'rotate-180')}\n            aria-hidden=\"true\"\n          />\n        </button>\n\n        <!-- Link panel -->\n        {#if linkOpen}\n          <div\n            bind:this={linkPanelEl}\n            class=\"bg-popover text-popover-foreground absolute top-full left-2 z-50 mt-1 w-72 rounded-md border p-3 shadow-md\"\n          >\n            <form\n              class=\"flex flex-col gap-2\"\n              onsubmit={(e) => {\n                e.preventDefault()\n                applyLink()\n              }}\n            >\n              <label for={linkFieldId} class=\"text-foreground text-xs font-medium\">Link URL</label>\n              <Input\n                id={linkFieldId}\n                bind:value={linkUrl}\n                type=\"url\"\n                size=\"small\"\n                placeholder=\"https://example.com\"\n                autocomplete=\"url\"\n                spellcheck={false}\n              />\n              <div class=\"flex items-center justify-end gap-2\">\n                {#if linkActive}\n                  <Button type=\"button\" variant=\"ghost\" size=\"sm\" onclick={removeLink}>Remove</Button>\n                {/if}\n                <Button type=\"submit\" size=\"sm\" disabled={!linkUrl.trim()}>Apply</Button>\n              </div>\n            </form>\n          </div>\n        {/if}\n      </div>\n\n      <!-- Extended row (collapsible) -->\n      <div class={cn('grid transition-colors duration-200 ease-in-out', showExtended ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0')}>\n        <div class=\"overflow-hidden\">\n          <div class=\"flex items-center gap-0.5 border-t px-2 py-1.5\">\n            {#each extendedItems as item, i (i)}\n              {#if item.type === 'separator'}\n                <Separator orientation=\"vertical\" class=\"mx-1 h-5\" />\n              {:else}\n                {@const ExtIcon = item.icon}\n                <Toggle\n                  size=\"sm\"\n                  pressed={item.isActive?.()}\n                  title={item.title}\n                  aria-label={item.title}\n                  class=\"focus-visible:ring-ring size-7 p-0 focus-visible:ring-2 focus-visible:outline-none\"\n                  onclick={() => item.action?.()}\n                >\n                  <ExtIcon class=\"size-3.5\" aria-hidden=\"true\" />\n                </Toggle>\n              {/if}\n            {/each}\n          </div>\n        </div>\n      </div>\n    </div>\n  {/if}\n\n  <!-- Editor -->\n  <div\n    bind:this={element}\n    class={cn('rich-text-content cursor-text overflow-y-auto px-3 py-2', editorClass)}\n    style=\"min-height: {minHeight};\"\n    onclick={() => editor?.commands.focus()}\n  ></div>\n</div>\n\n<style>\n  /* Editor placeholder */\n  :global(.rich-text-editor .tiptap p.is-editor-empty:first-child::before) {\n    content: attr(data-placeholder);\n    float: left;\n    color: var(--muted-foreground);\n    opacity: 0.5;\n    pointer-events: none;\n    height: 0;\n  }\n\n  /* Prose overrides for compact styling */\n  :global(.rich-text-content .tiptap) {\n    min-height: inherit;\n  }\n\n  :global(.rich-text-content .tiptap > *:first-child) {\n    margin-top: 0;\n  }\n\n  :global(.rich-text-content .tiptap > *:last-child) {\n    margin-bottom: 0;\n  }\n\n  :global(.rich-text-content .tiptap h1) {\n    font-size: 1.25rem;\n    font-weight: 700;\n    line-height: 1.3;\n    margin-top: 1rem;\n    margin-bottom: 0.5rem;\n  }\n\n  :global(.rich-text-content .tiptap h2) {\n    font-size: 1.1rem;\n    font-weight: 600;\n    line-height: 1.3;\n    margin-top: 0.75rem;\n    margin-bottom: 0.375rem;\n  }\n\n  :global(.rich-text-content .tiptap p) {\n    font-size: 0.875rem;\n    line-height: 1.6;\n    margin-top: 0.25rem;\n    margin-bottom: 0.25rem;\n  }\n\n  :global(.rich-text-content .tiptap ul),\n  :global(.rich-text-content .tiptap ol) {\n    padding-left: 1.25rem;\n    margin-top: 0.25rem;\n    margin-bottom: 0.25rem;\n  }\n\n  :global(.rich-text-content .tiptap li) {\n    font-size: 0.875rem;\n    margin-top: 0.125rem;\n    margin-bottom: 0.125rem;\n  }\n\n  :global(.rich-text-content .tiptap blockquote) {\n    border-left: 3px solid var(--border);\n    padding-left: 0.75rem;\n    margin-top: 0.5rem;\n    margin-bottom: 0.5rem;\n    color: var(--muted-foreground);\n    font-style: italic;\n  }\n\n  :global(.rich-text-content .tiptap code) {\n    background: var(--muted);\n    border-radius: 0.25rem;\n    padding: 0.125rem 0.25rem;\n    font-size: 0.8rem;\n    font-family: ui-monospace, monospace;\n  }\n\n  :global(.rich-text-content .tiptap pre) {\n    background: var(--muted);\n    border-radius: 0.5rem;\n    padding: 0.75rem 1rem;\n    margin-top: 0.5rem;\n    margin-bottom: 0.5rem;\n  }\n\n  :global(.rich-text-content .tiptap pre code) {\n    background: none;\n    padding: 0;\n    font-size: 0.8rem;\n  }\n\n  :global(.rich-text-content .tiptap hr) {\n    border-color: var(--border);\n    margin-top: 0.75rem;\n    margin-bottom: 0.75rem;\n  }\n\n  /* Task list styling */\n  :global(.rich-text-content .tiptap ul[data-type='taskList']) {\n    list-style: none;\n    padding-left: 0;\n  }\n\n  :global(.rich-text-content .tiptap ul[data-type='taskList'] li) {\n    display: flex;\n    align-items: flex-start;\n    gap: 0.5rem;\n  }\n\n  :global(.rich-text-content .tiptap ul[data-type='taskList'] li > label) {\n    flex-shrink: 0;\n    margin-top: 0.125rem;\n  }\n\n  :global(.rich-text-content .tiptap ul[data-type='taskList'] li > label input[type='checkbox']) {\n    accent-color: var(--primary);\n    width: 0.875rem;\n    height: 0.875rem;\n    cursor: pointer;\n  }\n\n  :global(.rich-text-content .tiptap ul[data-type='taskList'] li > div) {\n    flex: 1;\n  }\n\n  /* Link styling */\n  :global(.rich-text-content .tiptap a) {\n    color: var(--primary);\n    text-decoration: underline;\n    cursor: pointer;\n  }\n</style>\n",
      "type": "registry:ui",
      "target": "~/src/lib/components/ui/rich-text-editor/RichTextEditor.svelte"
    },
    {
      "path": "packages/registry-svelte/components/rich-text-editor/index.ts",
      "content": "export { default as RichTextEditor, type RichTextEditorProps } from './RichTextEditor.svelte'\n",
      "type": "registry:ui",
      "target": "~/src/lib/components/ui/rich-text-editor/index.ts"
    }
  ],
  "dependencies": [
    "@tiptap/core",
    "@tiptap/extension-link",
    "@tiptap/extension-placeholder",
    "@tiptap/extension-task-item",
    "@tiptap/extension-task-list",
    "@tiptap/extension-text-align",
    "@tiptap/extension-underline",
    "@tiptap/starter-kit"
  ],
  "devDependencies": [],
  "registryDependencies": [
    "https://uipkge.dev/r/svelte/button.json",
    "https://uipkge.dev/r/svelte/input.json",
    "https://uipkge.dev/r/svelte/separator.json",
    "https://uipkge.dev/r/svelte/toggle.json"
  ],
  "description": "WYSIWYG editor wrapped around TipTap — bold/italic/links/lists/headings/blockquote/code, plus a configurable toolbar. Drop into forms where Markdown is too low-level.",
  "categories": [
    "form"
  ]
}