Skip to content

RichText ​

Write formatted content without leaving the form. RichText gives you a compact toolbar, visual editing, and direct access to the source. Store HTML by default, or choose Markdown for a Markdown-backed workflow.

Preview Live · Vue
Usage
vue
<script setup>
import { ref } from 'vue'
import RichText from '@/components/ui/rich-text/RichText.vue'

const abstract = ref(
  '<p>A practical look at building resilient web applications, with the decisions and trade-offs behind a working demo.</p>'
)
</script>

<template>
  <div class="grid gap-2">
    <label for="proposal-abstract" class="text-sm font-medium"
      >Talk abstract</label
    >
    <RichText
      id="proposal-abstract"
      v-model="abstract"
      name="abstract"
      required
      aria-describedby="proposal-abstract-help"
      placeholder="The problem, your approach, and why it matters."
    />
    <p id="proposal-abstract-help" class="text-sm text-gray-600">
      Explain what attendees will learn. Use headings, lists, and links where
      they help.
    </p>
  </div>
</template>
jsx
import { useState } from 'react'
import RichText from '@/components/ui/rich-text/RichText.jsx'

export default function TalkAbstract() {
  const [abstract, setAbstract] = useState(
    '<p>A practical look at building resilient web applications, with the decisions and trade-offs behind a working demo.</p>'
  )

  return (
    <div className="grid gap-2">
      <label htmlFor="proposal-abstract" className="text-sm font-medium">
        Talk abstract
      </label>
      <RichText
        id="proposal-abstract"
        value={abstract}
        onValueChange={setAbstract}
        name="abstract"
        required
        aria-describedby="proposal-abstract-help"
        placeholder="The problem, your approach, and why it matters."
      />
      <p id="proposal-abstract-help" className="text-sm text-gray-600">
        Explain what attendees will learn. Use headings, lists, and links where
        they help.
      </p>
    </div>
  )
}
svelte
<script>
  import RichText from '@/components/ui/rich-text/RichText.svelte'

  let abstract = $state(
    '<p>A practical look at building resilient web applications, with the decisions and trade-offs behind a working demo.</p>'
  )
</script>

<div class="grid gap-2">
  <label for="proposal-abstract" class="text-sm font-medium"
    >Talk abstract</label
  >
  <RichText
    id="proposal-abstract"
    bind:value={abstract}
    name="abstract"
    required
    aria-describedby="proposal-abstract-help"
    placeholder="The problem, your approach, and why it matters."
  />
  <p id="proposal-abstract-help" class="text-sm text-gray-600">
    Explain what attendees will learn. Use headings, lists, and links where they
    help.
  </p>
</div>
View source TalkAbstract.vue
TalkAbstract.vue
<script setup>
import { ref } from 'vue'
import RichText from '@/components/ui/rich-text/RichText.vue'

const abstract = ref(
  '<p>A practical look at building resilient web applications, with the decisions and trade-offs behind a working demo.</p>'
)
</script>

<template>
  <div class="grid gap-2">
    <label for="proposal-abstract" class="text-sm font-medium"
      >Talk abstract</label
    >
    <RichText
      id="proposal-abstract"
      v-model="abstract"
      name="abstract"
      required
      aria-describedby="proposal-abstract-help"
      placeholder="The problem, your approach, and why it matters."
    />
    <p id="proposal-abstract-help" class="text-sm text-gray-600">
      Explain what attendees will learn. Use headings, lists, and links where
      they help.
    </p>
  </div>
</template>

Try formatting a sentence, adding a link, or switching to Source. The form keeps one value.

When to use ​

Use RichText for conference proposals, editorial descriptions, announcements, help articles, or notes that benefit from headings, lists, links, and images.

Use Textarea for plain text, code, logs, or a short note where formatting adds no value. RichText is an authoring field, not a page builder, collaborative document service, or code editor.

Installation ​

Run from your application’s directory. Klean detects your framework and copies the matching source.

Terminal
npx klean-ui add rich-text

Source you own. Edit the installed files to make them yours.

The command installs the matching framework source and its dependencies. The manual tab includes the editor, its helper, Popover, and the two toolbar icons.

Usage ​

Copy the example above for your framework.

Keep the label, help text, error message, and surrounding layout in your form. The bound value is a string—not an editor instance or a document object.

HTML or Markdown ​

format="html" is the default. Use format="markdown" when your application stores Markdown:

Preview Live · Vue
View source TalkOutline.vue
TalkOutline.vue
<script setup>
import { ref } from 'vue'
import RichText from '@/components/ui/rich-text/RichText.vue'

const outline = ref(
  '## What we will build\n\n- A working feature\n- A recovery path\n- Tests for the edge cases\n'
)
</script>

<template>
  <label for="talk-outline">Talk outline</label>
  <RichText
    id="talk-outline"
    v-model="outline"
    format="markdown"
    name="outline"
  />
</template>

vue
<script setup>
import { ref } from 'vue'
import RichText from '@/components/ui/rich-text/RichText.vue'

const outline = ref(
  '## What we will build\n\n- A working feature\n- A recovery path\n- Tests for the edge cases\n'
)
</script>

<template>
  <label for="talk-outline">Talk outline</label>
  <RichText
    id="talk-outline"
    v-model="outline"
    format="markdown"
    name="outline"
  />
</template>
jsx
import { useState } from 'react'
import RichText from '@/components/ui/rich-text/RichText.jsx'

export default function TalkOutline() {
  const [outline, setOutline] = useState(
    '## What we will build\n\n- A working feature\n- A recovery path\n- Tests for the edge cases\n'
  )

  return (
    <>
      <label htmlFor="talk-outline">Talk outline</label>
      <RichText
        id="talk-outline"
        value={outline}
        onValueChange={setOutline}
        format="markdown"
        name="outline"
      />
    </>
  )
}
svelte
<script>
  import RichText from '@/components/ui/rich-text/RichText.svelte'

  let outline = $state(
    '## What we will build\n\n- A working feature\n- A recovery path\n- Tests for the edge cases\n'
  )
</script>

<label for="talk-outline">Talk outline</label>
<RichText
  id="talk-outline"
  bind:value={outline}
  format="markdown"
  name="outline"
/>

Choose the format to match the stored field. Switching between Write and Source changes how you edit that field; it does not change its storage format.

Opening existing Markdown or switching modes does not rewrite the original string. A genuine visual edit can normalize Markdown syntax while preserving the supported content. If exact source syntax matters, continue in Source.

Content that the visual editor cannot preserve stays in Source with an explanation. This includes richer Markdown extensions or HTML outside the supported document structure. Your source is not silently reduced to fit the toolbar.

Forms and validation ​

Use ordinary id, name, form, required, disabled, readonly, and aria-* attributes. React uses readOnly; Vue and Svelte use readonly. An associated label focuses the editing surface. Native form submission includes the current HTML or Markdown string under name.

vue
<form action="/proposals" method="post">
  <label for="abstract">Talk abstract</label>
  <RichText id="abstract" v-model="abstract" name="abstract" required />
  <button type="submit">Submit proposal</button>
</form>

Apply your application's usual authentication and CSRF handling. Disabled fields are not submitted; read-only fields remain part of the form.

The length limit (maxlength, or maxLength in React) counts the stored string, including HTML markup or Markdown syntax, using the browser's UTF-16 string length. It is not a word limit or a count of visible characters. Keep this limit consistent with the backend field and validate it again on the server.

Connect a visible server error with aria-describedby and set aria-invalid when that error applies. Do not use the placeholder as the label.

The Link control edits a selected link or creates one from the current selection. Image insertion accepts a URL and descriptive alternative text. Use meaningful link text and an empty image alternative only when the image is genuinely decorative.

To upload images, pass your application's async upload function. It receives (file, { signal }) and returns { src, alt?, title? }:

uploadImage.js
// This route and its authentication/CSRF handling belong to your application.
export async function uploadImage(file, { signal }) {
  if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) {
    throw new Error('Choose a JPEG, PNG, or WebP image.')
  }
  if (file.size > 5 * 1024 * 1024) {
    throw new Error('Choose an image smaller than 5 MB.')
  }

  const body = new FormData()
  body.append('image', file)
  const response = await fetch('/uploads/images', {
    method: 'POST',
    body,
    signal
  })
  if (!response.ok) throw new Error('The image could not be uploaded.')

  const image = await response.json()
  return { src: image.url, alt: image.alt || '', title: image.title }
}

vue
<RichText v-model="body" :upload="uploadImage" />
jsx
<RichText value={body} onValueChange={setBody} upload={uploadImage} />
svelte
<RichText bind:value={body} upload={uploadImage} />

Honor the supplied abort signal and return a durable, server-owned image URL. The example endpoint and file policy are application choices. Repeat file type, size, authorization, and content checks on the server; client checks are only feedback. Do not persist a temporary blob URL as an uploaded image.

A toolbar that fits your application ​

Use the compact default toolbar for ordinary authoring. Replace it when a workflow needs fewer actions or an application-specific arrangement:

Preview Live · Vue
View source ReviewNote.vue
ReviewNote.vue
<script setup>
import { ref } from 'vue'
import RichText from '@/components/ui/rich-text/RichText.vue'

const note = ref('<p>A short note for the review team.</p>')
</script>

<template>
  <label for="review-note">Review note</label>
  <RichText id="review-note" v-model="note">
    <template #toolbar="{ editor, mode, setMode, openLink }">
      <div
        role="group"
        class="flex flex-wrap items-center gap-1 border-b border-gray-200 p-2 *:min-h-9 *:rounded *:px-3"
        aria-label="Text formatting"
      >
        <button
          type="button"
          :disabled="!editor?.isEditable || mode !== 'visual'"
          :aria-pressed="editor?.isActive('bold') || false"
          @click="editor?.chain().focus().toggleBold().run()"
        >
          Bold
        </button>
        <button
          type="button"
          :disabled="!editor?.isEditable || mode !== 'visual'"
          @click="openLink"
        >
          Link
        </button>
        <button
          type="button"
          :aria-pressed="mode === 'source'"
          @click="setMode(mode === 'source' ? 'visual' : 'source')"
        >
          Source
        </button>
      </div>
    </template>
  </RichText>
</template>

vue
<script setup>
import { ref } from 'vue'
import RichText from '@/components/ui/rich-text/RichText.vue'

const note = ref('<p>A short note for the review team.</p>')
</script>

<template>
  <label for="review-note">Review note</label>
  <RichText id="review-note" v-model="note">
    <template #toolbar="{ editor, mode, setMode, openLink }">
      <div
        role="group"
        class="flex flex-wrap items-center gap-1 border-b border-gray-200 p-2 *:min-h-9 *:rounded *:px-3"
        aria-label="Text formatting"
      >
        <button
          type="button"
          :disabled="!editor?.isEditable || mode !== 'visual'"
          :aria-pressed="editor?.isActive('bold') || false"
          @click="editor?.chain().focus().toggleBold().run()"
        >
          Bold
        </button>
        <button
          type="button"
          :disabled="!editor?.isEditable || mode !== 'visual'"
          @click="openLink"
        >
          Link
        </button>
        <button
          type="button"
          :aria-pressed="mode === 'source'"
          @click="setMode(mode === 'source' ? 'visual' : 'source')"
        >
          Source
        </button>
      </div>
    </template>
  </RichText>
</template>
jsx
import { useState } from 'react'
import RichText from '@/components/ui/rich-text/RichText.jsx'

export default function ReviewNote() {
  const [note, setNote] = useState('<p>A short note for the review team.</p>')

  return (
    <>
      <label htmlFor="review-note">Review note</label>
      <RichText
        id="review-note"
        value={note}
        onValueChange={setNote}
        renderToolbar={({ editor, mode, setMode, openLink }) => (
          <div
            role="group"
            className="flex flex-wrap items-center gap-1 border-b border-gray-200 p-2 *:min-h-9 *:rounded *:px-3"
            aria-label="Text formatting"
          >
            <button
              type="button"
              disabled={!editor?.isEditable || mode !== 'visual'}
              aria-pressed={editor?.isActive('bold') || false}
              onClick={() => editor?.chain().focus().toggleBold().run()}
            >
              Bold
            </button>
            <button
              type="button"
              disabled={!editor?.isEditable || mode !== 'visual'}
              onClick={openLink}
            >
              Link
            </button>
            <button
              type="button"
              aria-pressed={mode === 'source'}
              onClick={() => setMode(mode === 'source' ? 'visual' : 'source')}
            >
              Source
            </button>
          </div>
        )}
      />
    </>
  )
}
svelte
<script>
  import RichText from '@/components/ui/rich-text/RichText.svelte'

  let note = $state('<p>A short note for the review team.</p>')
</script>

<label for="review-note">Review note</label>
<RichText id="review-note" bind:value={note}>
  {#snippet toolbar({ editor, mode, setMode, openLink })}
    <div
      role="group"
      class="flex flex-wrap items-center gap-1 border-b border-gray-200 p-2 *:min-h-9 *:rounded *:px-3"
      aria-label="Text formatting"
    >
      <button
        type="button"
        disabled={!editor?.isEditable || mode !== 'visual'}
        aria-pressed={editor?.isActive('bold') || false}
        onclick={() => editor?.chain().focus().toggleBold().run()}>Bold</button
      >
      <button
        type="button"
        disabled={!editor?.isEditable || mode !== 'visual'}
        onclick={openLink}>Link</button
      >
      <button
        type="button"
        aria-pressed={mode === 'source'}
        onclick={() => setMode(mode === 'source' ? 'visual' : 'source')}
        >Source</button
      >
    </div>
  {/snippet}
</RichText>

Vue's toolbar slot, React's renderToolbar, and Svelte's toolbar snippet receive { editor, mode, setMode, openLink, openImage }. editor is the Tiptap editor instance. mode is 'visual' (Write) or 'source'. Use editor commands for supported document actions; openLink() and openImage() reuse the editor's insertion controls.

Use real button type="button" actions, expose toggle state with aria-pressed, and keep commands unavailable when the document is not editable. If you add a floating toolbar, its placement and presentation belong to your application's composition.

Recoverable drafts ​

RichText keeps its value in your form. It does not choose a storage key, save to a server, or silently enable browser persistence.

For substantial, non-sensitive authoring work, compose it with the existing Durable UI form-draft utility:

bash
npx klean-ui add durable-ui
RecoverableProposal.vue
<script setup>
import { reactive, ref } from 'vue'
import RichText from '@/components/ui/rich-text/RichText.vue'
import { useFormDraft } from '@/components/ui/durable-ui/useFormDraft.js'

// The page supplies a key scoped to the account, event, and proposal.
const props = defineProps({
  draftKey: { type: String, required: true },
  saveProposal: { type: Function, required: true }
})
const form = reactive({ abstract: '' })
const saving = ref(false)
const error = ref('')
const draft = useFormDraft(props.draftKey, form)

async function submit() {
  saving.value = true
  error.value = ''
  try {
    await props.saveProposal({ ...form })
    draft.clear()
  } catch {
    error.value = 'Your proposal could not be saved. Your draft is still here.'
  } finally {
    saving.value = false
  }
}
</script>

<template>
  <aside
    v-if="draft.hasDraft.value && !draft.dirty.value && !draft.restored.value"
    aria-label="Recovered draft"
  >
    <p>A saved proposal draft is available.</p>
    <button type="button" @click="draft.restore">Restore draft</button>
    <button type="button" @click="draft.discard">Discard draft</button>
  </aside>
  <form class="grid gap-3" @submit.prevent="submit">
    <label for="draft-abstract">Talk abstract</label>
    <RichText
      id="draft-abstract"
      v-model="form.abstract"
      name="abstract"
      required
      :readonly="saving"
    />
    <p v-if="error" role="alert">{{ error }}</p>
    <button type="submit" :disabled="saving || !draft.dirty.value">
      {{ saving ? 'Saving…' : 'Save proposal' }}
    </button>
  </form>
</template>

Scope the draft key to the account, event, and record. Offer restore or discard, retain the draft when saving fails, and clear it only after confirmed success. Use server drafts when content is sensitive or must follow the author across devices. Do not store private authoring content in a shareable URL.

Styling ​

Use ordinary classes on the root and target named inner parts when needed:

vue
<RichText
  v-model="abstract"
  class="rounded-xl border-dashed **:data-[slot=rich-text-content]:min-h-64 **:data-[slot=rich-text-toolbar]:bg-gray-50"
/>

The inner hooks are rich-text-toolbar, rich-text-content, rich-text-source, rich-text-status, and rich-text-tool. React accepts className; Vue and Svelte accept class. Keep visible focus, readable contrast, wrapping controls, and access to long content when customizing.

Content safety ​

Visual content is sanitized before it is displayed. That does not turn an authored source string into trusted HTML, nor does viewing a value silently replace what your application stores.

Validate and sanitize authored content on the server according to your application's rendering policy. Render untrusted HTML or Markdown only through that policy; do not pass the raw stored string directly to an HTML insertion API and assume the editor has made it safe.

Complete framework source ​

vue
<script setup>
import {
  computed,
  nextTick,
  onBeforeUnmount,
  onMounted,
  ref,
  useAttrs,
  useId,
  watch
} from 'vue'
import { EditorContent, useEditor } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import ImageExtension from '@tiptap/extension-image'
import FileHandler from '@tiptap/extension-file-handler'
import Placeholder from '@tiptap/extension-placeholder'
import { Markdown } from '@tiptap/markdown'
import { history } from '@tiptap/pm/history'
import { twMerge } from 'tailwind-merge'
import Popover from '../popover/Popover.vue'
import Link from '../icons/Link.vue'
import Image from '../icons/Image.vue'
import {
  inspectMarkdown,
  inspectRichTextHtml,
  htmlRoundTripMatches,
  normalizeLinkUrl,
  normalizeImageUrl,
  preserveMarkdownEnvelope,
  roundTripMatches,
  sanitizeRichTextHtml
} from './rich-text.js'

defineOptions({ inheritAttrs: false })
const props = defineProps({
  modelValue: { type: String, default: undefined },
  format: {
    type: String,
    default: 'html',
    validator: (value) => ['html', 'markdown'].includes(value)
  },
  placeholder: { type: String, default: 'Start writing…' },
  disabled: Boolean,
  readonly: Boolean,
  required: Boolean,
  maxlength: { type: [Number, String], default: undefined },
  upload: { type: Function, default: undefined }
})
const emit = defineEmits(['update:modelValue', 'blur', 'mode-change'])
const attrs = useAttrs()
const generatedId = useId()
const root = ref()
const source = ref()
const toolbar = ref()
const popupAnchor = ref()
const linkInput = ref()
const imageInput = ref()
const imageFile = ref()
const ready = ref(false)
const mode = ref('visual')
const sourceValue = ref(props.modelValue ?? attrs.value ?? '')
const initialValue = sourceValue.value
const warning = ref('')
const status = ref('')
const validationError = ref('')
const popup = ref('')
const linkUrl = ref('')
const imageUrl = ref('')
const imageAlt = ref('')
const popupError = ref('')
const uploading = ref(false)
const labelText = ref('')
const toolbarIndex = ref(0)
const fieldId = computed(
  () => attrs.id ?? `klean-rich-text-${generatedId.replace(/[^\w-]/g, '')}`
)
const locked = computed(() => props.disabled || props.readonly)
const popupOpen = computed({
  get: () => Boolean(popup.value),
  set: (value) => {
    if (!value) popup.value = ''
  }
})
const description = computed(
  () =>
    [
      attrs['aria-describedby'],
      warning.value && `${fieldId.value}-warning`,
      validationError.value && `${fieldId.value}-error`
    ]
      .filter(Boolean)
      .join(' ') || undefined
)
const accessibleLabel = computed(() =>
  attrs['aria-labelledby']
    ? undefined
    : (attrs['aria-label'] ?? (labelText.value || 'Rich text'))
)
const nativeAttrs = computed(() => {
  const {
    class: _class,
    style: _style,
    value: _value,
    id: _id,
    'data-slot': _slot,
    'aria-label': _label,
    'aria-describedby': _description,
    'aria-invalid': _invalid,
    onFocus: _focus,
    ...rest
  } = attrs
  return rest
})
let syncing = false
let composing = false
let destroyed = false
let pendingExternal
let parentForm
let selectionBookmark
let selectedImage = false
let pendingUploads = new Set()
let renderedSource

const toolClass =
  'inline-flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-md text-sm text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 aria-pressed:bg-gray-100 aria-pressed:text-gray-950 disabled:cursor-not-allowed disabled:opacity-35 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-white dark:aria-pressed:bg-gray-800 dark:aria-pressed:text-white dark:focus-visible:outline-white motion-reduce:transition-none'
const fieldClass =
  'min-h-10 w-full min-w-0 rounded-md border border-gray-300 bg-transparent px-3 py-2 text-sm outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 dark:border-gray-700 dark:focus-visible:outline-white'
const actionClass =
  'min-h-10 cursor-pointer rounded-md bg-gray-950 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-white dark:text-gray-950 dark:hover:bg-gray-200'
const contentClass =
  'min-h-56 w-full min-w-0 px-5 py-5 text-base/7 outline-none wrap-anywhere *:first:mt-0 *:last:mb-0 [&_p]:my-3 [&_h1]:mt-7 [&_h1]:mb-3 [&_h1]:text-3xl [&_h1]:font-semibold [&_h1]:tracking-tight [&_h2]:mt-6 [&_h2]:mb-3 [&_h2]:text-2xl [&_h2]:font-semibold [&_h2]:tracking-tight [&_h3]:mt-5 [&_h3]:mb-2 [&_h3]:text-xl [&_h3]:font-semibold [&_h4]:mt-4 [&_h4]:font-semibold [&_h5]:font-semibold [&_h6]:font-semibold [&_strong]:font-semibold [&_a]:text-blue-700 [&_a]:underline [&_a]:underline-offset-3 [&_ul]:my-3 [&_ul]:list-disc [&_ul]:ps-6 [&_ol]:my-3 [&_ol]:list-decimal [&_ol]:ps-6 [&_li]:my-1 [&_li>p]:my-1 [&_blockquote]:my-4 [&_blockquote]:border-s-2 [&_blockquote]:border-gray-300 [&_blockquote]:ps-4 [&_blockquote]:text-gray-600 [&_pre]:my-4 [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:bg-gray-100 [&_pre]:p-4 [&_pre]:text-sm [&_code]:rounded [&_code]:bg-gray-100 [&_code]:px-1 [&_code]:font-mono [&_code]:text-[0.9em] [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_img]:my-4 [&_img]:max-h-96 [&_img]:max-w-full [&_img]:rounded-lg [&_img]:object-contain [&_hr]:my-6 [&_hr]:border-gray-200 [&_.ProseMirror-selectednode]:outline-2 [&_.ProseMirror-selectednode]:outline-blue-500 [&_p.is-editor-empty:first-child]:before:pointer-events-none [&_p.is-editor-empty:first-child]:before:float-start [&_p.is-editor-empty:first-child]:before:h-0 [&_p.is-editor-empty:first-child]:before:text-gray-500 [&_p.is-editor-empty:first-child]:before:content-[attr(data-placeholder)] dark:[&_a]:text-blue-400 dark:[&_blockquote]:border-gray-600 dark:[&_blockquote]:text-gray-400 dark:[&_pre]:bg-gray-900 dark:[&_code]:bg-gray-900 dark:[&_hr]:border-gray-800'

const editor = useEditor({
  immediatelyRender: false,
  content: '',
  extensions: [
    StarterKit.configure({
      underline: false,
      link: {
        openOnClick: false,
        autolink: true,
        defaultProtocol: 'https',
        isAllowedUri: (value) => Boolean(normalizeLinkUrl(value)),
        HTMLAttributes: { rel: 'noopener noreferrer', target: null }
      }
    }),
    ImageExtension.configure({ allowBase64: false }),
    Placeholder.configure({ placeholder: () => props.placeholder }),
    Markdown,
    FileHandler.configure({
      allowedMimeTypes: [
        'image/png',
        'image/jpeg',
        'image/gif',
        'image/webp',
        'image/avif'
      ],
      onPaste: (current, files) =>
        uploadFiles(files, current.state.selection.from),
      onDrop: (_current, files, position) => uploadFiles(files, position)
    })
  ],
  editorProps: {
    attributes: {
      role: 'textbox',
      'aria-multiline': 'true',
      'data-slot': 'rich-text-content',
      class: contentClass
    },
    transformPastedHTML: (html) => sanitizeRichTextHtml(html),
    handleKeyDown: (_view, event) => {
      if (event.isComposing) return false
      if (event.altKey && event.key === 'F10') {
        event.preventDefault()
        focusToolbar()
        return true
      }
      if (
        (event.metaKey || event.ctrlKey) &&
        event.key.toLowerCase() === 'k' &&
        !locked.value
      ) {
        event.preventDefault()
        openLink()
        return true
      }
      return false
    },
    handleDOMEvents: {
      focus: (_view, event) => {
        attrs.onFocus?.(event)
        return false
      },
      compositionstart: () => {
        composing = true
        return false
      },
      compositionend: () => {
        composing = false
        queueMicrotask(() => {
          if (destroyed) return
          if (pendingExternal !== undefined) flushExternal()
          else commitEditor()
        })
        return false
      }
    }
  },
  onCreate: ({ editor: current }) => {
    loadValue(sourceValue.value, current)
    syncEditorAttributes(current)
    ready.value = true
    nextTick(() => {
      syncValidity()
      syncToolbar()
    })
  },
  onUpdate: () => {
    if (!composing) commitEditor()
  },
  onTransaction: ({ transaction }) => {
    nextTick(syncToolbar)
    if (!transaction.docChanged) return
    for (const job of pendingUploads)
      job.position = transaction.mapping.map(job.position, 1)
    if (selectionBookmark)
      selectionBookmark = selectionBookmark.map(transaction.mapping)
  }
})

function serialize(current = editor.value) {
  return props.format === 'markdown'
    ? preserveMarkdownEnvelope(current.getMarkdown(), sourceValue.value)
    : current.getHTML()
}
function commitEditor() {
  if (
    !editor.value ||
    syncing ||
    locked.value ||
    mode.value !== 'visual' ||
    pendingExternal !== undefined
  )
    return
  const value = editor.value.isEmpty ? '' : serialize()
  if (value === sourceValue.value) return
  sourceValue.value = value
  renderedSource = value
  emit('update:modelValue', value)
  nextTick(syncValidity)
}
function inspect(value) {
  return props.format === 'markdown'
    ? inspectMarkdown(value)
    : inspectRichTextHtml(value)
}
function loadValue(value, current = editor.value) {
  if (!current) return false
  if (value === renderedSource) {
    warning.value = ''
    return true
  }
  const inspection = inspect(value)
  if (!inspection.supported) {
    warning.value = `Keep editing the source to preserve ${inspection.issues.map((issue) => issue.label).join(', ')}.`
    changeMode('source')
    return false
  }
  syncing = true
  renderedSource = undefined
  try {
    current.commands.setContent(
      props.format === 'html' ? sanitizeRichTextHtml(value) : value,
      { contentType: props.format, emitUpdate: false }
    )
    if (
      props.format === 'html' &&
      !htmlRoundTripMatches(value, current.getHTML())
    ) {
      warning.value =
        'This content needs source editing so none of its formatting is lost.'
      changeMode('source')
      return false
    }
    if (
      props.format === 'markdown' &&
      !roundTripMatches(
        value,
        preserveMarkdownEnvelope(current.getMarkdown(), value),
        (content) => current.markdown.parse(content)
      )
    ) {
      warning.value =
        'This content needs source editing so none of its formatting is lost.'
      changeMode('source')
      return false
    }
    warning.value = ''
    renderedSource = value
    // Loading a different document is not an author edit. Start its undo
    // history here, without retaining content from the previous record.
    const historyKey = history().spec.key
    const historyPlugin = historyKey.get(current.state)
    if (historyPlugin) {
      current.unregisterPlugin(historyKey)
      current.registerPlugin(historyPlugin)
    }
    return true
  } catch {
    warning.value =
      'This content could not be opened safely. Your source is still intact.'
    changeMode('source')
    return false
  } finally {
    syncing = false
  }
}
function changeMode(value) {
  if (mode.value === value) return
  mode.value = value
  emit('mode-change', value)
}
async function setMode(value) {
  if (
    props.disabled ||
    !['visual', 'source'].includes(value) ||
    value === mode.value
  )
    return
  abortUploads()
  popup.value = ''
  if (value === 'visual' && !loadValue(sourceValue.value)) return
  changeMode(value)
  syncEditorAttributes()
  await nextTick()
  focus()
  syncValidity()
}
function updateSource(event) {
  if (locked.value) return
  sourceValue.value = event.target.value
  if (event.isComposing || composing) return
  warning.value = ''
  emit('update:modelValue', sourceValue.value)
  syncValidity()
}
function flushExternal() {
  if (pendingExternal === undefined) return
  const value = pendingExternal
  pendingExternal = undefined
  replaceValue(value)
}
function replaceValue(value) {
  if (value === sourceValue.value) return
  if (composing || editor.value?.view.composing) {
    pendingExternal = value
    return
  }
  abortUploads()
  popup.value = ''
  sourceValue.value = value
  if (mode.value === 'visual') loadValue(value)
  warning.value = mode.value === 'source' ? warning.value : ''
  nextTick(syncValidity)
}
function syncEditorAttributes(current = editor.value) {
  if (!current || destroyed) return
  current.setEditable(!locked.value && mode.value === 'visual', false)
  current.setOptions({
    editorProps: {
      attributes: () =>
        Object.fromEntries(
          Object.entries({
            role: 'textbox',
            'aria-multiline': 'true',
            'data-slot': 'rich-text-content',
            class: contentClass,
            id: `${fieldId.value}-editor`,
            'aria-label': accessibleLabel.value,
            'aria-labelledby': attrs['aria-labelledby'],
            'aria-describedby': description.value,
            'aria-required': props.required ? 'true' : undefined,
            'aria-invalid': validationError.value
              ? 'true'
              : attrs['aria-invalid'],
            'aria-readonly': props.readonly ? 'true' : undefined,
            'aria-disabled': props.disabled ? 'true' : undefined,
            tabindex: props.disabled ? '-1' : '0',
            spellcheck: attrs.spellcheck ?? 'true',
            dir: attrs.dir
          }).filter(([, value]) => value != null)
        )
    }
  })
}
function isEmpty() {
  if (!sourceValue.value.trim()) return true
  return mode.value === 'visual' && editor.value
    ? editor.value.isEmpty
    : props.format === 'html' &&
        !sanitizeRichTextHtml(sourceValue.value)
          .replace(/<[^>]+>/g, '')
          .replace(/&nbsp;/g, ' ')
          .trim() &&
        !/<img\b/i.test(sanitizeRichTextHtml(sourceValue.value))
}
function validityMessage() {
  if (locked.value) return ''
  if (props.required && isEmpty()) return 'Please fill out this field.'
  const max = Number(props.maxlength)
  if (
    props.maxlength !== undefined &&
    Number.isFinite(max) &&
    sourceValue.value.length > max
  )
    return `Use ${max} characters or fewer, including formatting.`
  return ''
}
function syncValidity() {
  const message = validityMessage()
  source.value?.setCustomValidity(message)
  if (validationError.value) validationError.value = message
  syncEditorAttributes()
}
function invalid(event) {
  event.preventDefault()
  validationError.value =
    validityMessage() || source.value?.validationMessage || 'Check this field.'
  syncEditorAttributes()
  focus()
}
function focus(options) {
  if (props.disabled) return
  if (ready.value && mode.value === 'visual')
    editor.value?.view.dom.focus(options)
  else source.value?.focus(options)
}
function tools() {
  return [...(toolbar.value?.querySelectorAll('button:not(:disabled)') ?? [])]
}
function focusToolbar() {
  syncToolbar()
  toolbar.value?.querySelector('button[tabindex="0"]')?.focus()
}
function syncToolbar() {
  const buttons = [...(toolbar.value?.querySelectorAll('button') ?? [])]
  const current = buttons[toolbarIndex.value]
  if (!current || current.disabled)
    toolbarIndex.value = buttons.findIndex((button) => !button.disabled)
}
function toolbarKeydown(event) {
  const buttons = tools()
  const index = buttons.indexOf(event.target)
  if (index < 0) return
  if (event.key === 'Escape') {
    event.preventDefault()
    focus()
    return
  }
  const rtl = getComputedStyle(toolbar.value).direction === 'rtl'
  let target
  if (event.key === 'Home') target = 0
  else if (event.key === 'End') target = buttons.length - 1
  else if (event.key === 'ArrowRight')
    target = (index + (rtl ? -1 : 1) + buttons.length) % buttons.length
  else if (event.key === 'ArrowLeft')
    target = (index + (rtl ? 1 : -1) + buttons.length) % buttons.length
  else return
  event.preventDefault()
  toolbarIndex.value = [...toolbar.value.querySelectorAll('button')].indexOf(
    buttons[target]
  )
  buttons[target]?.focus()
}
function run(command, attributes) {
  if (locked.value || mode.value !== 'visual') return
  editor.value?.chain().focus()[command](attributes).run()
}
function rememberSelection() {
  if (!editor.value) return
  selectionBookmark = editor.value.state.selection.getBookmark()
  selectedImage = editor.value.isActive('image')
}
function restoreSelection() {
  if (!selectionBookmark || !editor.value) return
  try {
    editor.value.view.dispatch(
      editor.value.state.tr.setSelection(
        selectionBookmark.resolve(editor.value.state.doc)
      )
    )
  } catch {
    /* The selected content may have been removed. */
  }
}
async function openLink(event) {
  if (locked.value || mode.value !== 'visual') return
  rememberSelection()
  popupAnchor.value =
    event?.currentTarget ??
    root.value?.querySelector('[data-action="link"]') ??
    editor.value?.view.dom
  linkUrl.value = editor.value?.getAttributes('link').href ?? ''
  popupError.value = ''
  popup.value = 'link'
  await nextTick()
  linkInput.value?.focus()
}
async function openImage(event) {
  if (locked.value || mode.value !== 'visual') return
  rememberSelection()
  popupAnchor.value =
    event?.currentTarget ??
    root.value?.querySelector('[data-action="image"]') ??
    editor.value?.view.dom
  imageUrl.value = selectedImage ? editor.value.getAttributes('image').src : ''
  imageAlt.value = selectedImage
    ? (editor.value.getAttributes('image').alt ?? '')
    : ''
  popupError.value = ''
  popup.value = 'image'
  await nextTick()
  imageInput.value?.focus()
}
function closePopup(returnFocus = true) {
  popup.value = ''
  if (returnFocus) {
    restoreSelection()
    focus()
  }
}
function applyLink() {
  const href = normalizeLinkUrl(linkUrl.value)
  if (!href) {
    popupError.value = 'Enter a safe web, email, phone or relative link.'
    return
  }
  restoreSelection()
  const chain = editor.value.chain().focus().extendMarkRange('link')
  if (editor.value.state.selection.empty && !editor.value.isActive('link'))
    chain
      .insertContent({
        type: 'text',
        text: linkUrl.value.trim(),
        marks: [{ type: 'link', attrs: { href } }]
      })
      .run()
  else chain.setLink({ href }).run()
  closePopup()
}
function removeLink() {
  restoreSelection()
  editor.value?.chain().focus().extendMarkRange('link').unsetLink().run()
  closePopup()
}
function applyImage() {
  const src = normalizeImageUrl(imageUrl.value)
  if (!src) {
    popupError.value = 'Enter a safe image URL.'
    return
  }
  restoreSelection()
  editor.value
    ?.chain()
    .focus()
    .setImage({ src, alt: imageAlt.value.trim() })
    .run()
  closePopup()
}
function abortUploads() {
  if (pendingUploads.size) status.value = ''
  for (const job of pendingUploads) job.controller.abort()
  pendingUploads.clear()
  uploading.value = false
}
async function uploadFiles(
  files,
  position = editor.value?.state.selection.from
) {
  if (locked.value || mode.value !== 'visual' || !editor.value) return
  if (!props.upload) {
    status.value = 'Choose an image URL. File uploads are not configured.'
    return
  }
  const current = editor.value
  const controller = new AbortController()
  const job = { controller, position }
  pendingUploads.add(job)
  uploading.value = true
  status.value = 'Uploading image…'
  closePopup(false)
  try {
    for (const file of Array.from(files)) {
      if (
        ![
          'image/png',
          'image/jpeg',
          'image/gif',
          'image/webp',
          'image/avif'
        ].includes(file.type)
      )
        throw new Error('Choose a PNG, JPEG, GIF, WebP or AVIF image.')
      const result = await props.upload(file, { signal: controller.signal })
      if (
        destroyed ||
        controller.signal.aborted ||
        locked.value ||
        mode.value !== 'visual' ||
        editor.value !== current
      )
        return
      const src = normalizeImageUrl(result?.src)
      if (!src) throw new Error('The upload did not return a safe image URL.')
      const position = Math.min(job.position, current.state.doc.content.size)
      current.commands.insertContentAt(
        position,
        {
          type: 'image',
          attrs: { src, alt: result.alt ?? '', title: result.title ?? null }
        },
        { updateSelection: false }
      )
    }
    status.value = 'Image added. Select it to edit its description.'
  } catch (error) {
    if (!destroyed && !controller.signal.aborted)
      status.value =
        error.message || 'The image could not be uploaded. Try again.'
  } finally {
    pendingUploads.delete(job)
    if (!destroyed) uploading.value = pendingUploads.size > 0
  }
}
function compositeBlur(event) {
  const destination = event.relatedTarget
  if (
    destination &&
    (root.value?.contains(destination) ||
      destination.closest?.(`[data-rich-text-owner="${fieldId.value}"]`))
  )
    return
  queueMicrotask(() => {
    if (destroyed) return
    const active = root.value?.getRootNode().activeElement
    if (
      root.value?.contains(active) ||
      active?.closest?.(`[data-rich-text-owner="${fieldId.value}"]`)
    )
      return
    emit('blur', event)
  })
}
function reset(event) {
  queueMicrotask(() => {
    if (event.defaultPrevented || destroyed) return
    abortUploads()
    sourceValue.value = initialValue
    warning.value = status.value = validationError.value = ''
    popup.value = ''
    if (loadValue(initialValue)) changeMode('visual')
    emit('update:modelValue', initialValue)
    nextTick(syncValidity)
  })
}
onMounted(() => {
  labelText.value = [...(source.value?.labels ?? [])]
    .map((label) => label.textContent.trim())
    .join(' ')
  parentForm = source.value?.form
  parentForm?.addEventListener('reset', reset)
  syncEditorAttributes()
})
onBeforeUnmount(() => {
  destroyed = true
  abortUploads()
  parentForm?.removeEventListener('reset', reset)
})
watch(() => props.modelValue ?? attrs.value ?? '', replaceValue)
watch(
  () => props.format,
  () => {
    abortUploads()
    renderedSource = undefined
    loadValue(sourceValue.value)
    nextTick(syncValidity)
  }
)
watch(
  () => [
    locked.value,
    props.required,
    props.maxlength,
    props.placeholder,
    attrs['aria-label'],
    attrs['aria-labelledby'],
    attrs['aria-describedby'],
    attrs['aria-invalid'],
    attrs.id,
    attrs.dir
  ],
  () => {
    if (locked.value) {
      abortUploads()
      popup.value = ''
    }
    syncEditorAttributes()
    syncValidity()
  }
)
watch(mode, () => nextTick(syncEditorAttributes))
defineExpose({
  editor,
  focus,
  setMode,
  getMode: () => mode.value,
  checkValidity: () => {
    syncValidity()
    return source.value?.checkValidity() ?? true
  },
  reportValidity: () => {
    syncValidity()
    return source.value?.reportValidity() ?? true
  }
})
</script>

<template>
  <div
    ref="root"
    data-slot="rich-text"
    :data-mode="mode"
    :data-disabled="disabled || undefined"
    :data-readonly="readonly || undefined"
    :style="attrs.style"
    :class="
      twMerge(
        'relative w-full min-w-0 rounded-xl border border-gray-200 bg-white text-gray-950 shadow-sm focus-within:border-gray-400 focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-gray-950/15 data-disabled:opacity-50 dark:border-gray-800 dark:bg-gray-950 dark:text-gray-100 dark:focus-within:border-gray-600 dark:focus-within:outline-white/20',
        attrs.class
      )
    "
    @focusout="compositeBlur"
  >
    <slot
      name="toolbar"
      :editor="editor"
      :mode="mode"
      :set-mode="setMode"
      :open-link="openLink"
      :open-image="openImage"
    >
      <div
        data-slot="rich-text-toolbar"
        class="flex flex-wrap items-center gap-2 rounded-t-[inherit] bg-gray-50/70 p-2 dark:bg-gray-900/50"
      >
        <div
          ref="toolbar"
          role="toolbar"
          aria-label="Text formatting"
          class="flex min-w-0 flex-wrap items-center gap-0.5"
          @keydown="toolbarKeydown"
        >
          <button
            v-for="(tool, index) in [
              {
                name: 'Heading',
                text: 'H₂',
                command: 'toggleHeading',
                attributes: { level: 2 },
                active: 'heading'
              },
              {
                name: 'Bold',
                text: 'B',
                command: 'toggleBold',
                active: 'bold',
                class: 'font-bold'
              },
              {
                name: 'Italic',
                text: 'I',
                command: 'toggleItalic',
                active: 'italic',
                class: 'font-serif italic'
              },
              {
                name: 'Strikethrough',
                text: 'S',
                command: 'toggleStrike',
                active: 'strike',
                class: 'line-through'
              },
              {
                name: 'Bullet list',
                path: 'M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01',
                command: 'toggleBulletList',
                active: 'bulletList'
              },
              {
                name: 'Numbered list',
                path: 'M10 6h10M10 12h10M10 18h10M3 5l1-1v5M3 9h2M3 15a1.5 1.5 0 0 1 3 0c0 1-3 2-3 4h3',
                command: 'toggleOrderedList',
                active: 'orderedList'
              },
              {
                name: 'Quote',
                path: 'M10 6H4v6h5c0 3-2 5-5 6M20 6h-6v6h5c0 3-2 5-5 6',
                command: 'toggleBlockquote',
                active: 'blockquote'
              },
              {
                name: 'Code',
                path: 'm8 7-5 5 5 5m8-10 5 5-5 5m-3-14-2 18',
                command: 'toggleCodeBlock',
                active: 'codeBlock',
                class: 'font-mono text-xs'
              }
            ]"
            :key="tool.name"
            type="button"
            data-slot="rich-text-tool"
            :class="twMerge(toolClass, tool.class)"
            :aria-label="tool.name"
            :title="tool.name"
            :aria-pressed="
              editor?.isActive(tool.active, tool.attributes) ?? false
            "
            :disabled="!ready || locked || mode === 'source'"
            :tabindex="toolbarIndex === index ? 0 : -1"
            @focus="toolbarIndex = index"
            @mousedown.prevent
            @click="run(tool.command, tool.attributes)"
          >
            <svg
              v-if="tool.path"
              class="size-4"
              viewBox="0 0 24 24"
              fill="none"
              stroke="currentColor"
              stroke-width="1.5"
              stroke-linecap="round"
              stroke-linejoin="round"
              aria-hidden="true"
              focusable="false"
            >
              <path :d="tool.path" />
            </svg>
            <span v-else>{{ tool.text }}</span>
          </button>
          <button
            type="button"
            data-action="link"
            data-slot="rich-text-tool"
            :class="toolClass"
            aria-label="Link"
            title="Link · Ctrl/⌘ K"
            :aria-pressed="editor?.isActive('link') ?? false"
            :disabled="!ready || locked || mode === 'source'"
            :tabindex="toolbarIndex === 8 ? 0 : -1"
            @focus="toolbarIndex = 8"
            @mousedown.prevent
            @click="openLink"
          >
            <Link class="size-4" />
          </button>
          <button
            type="button"
            data-action="image"
            data-slot="rich-text-tool"
            :class="toolClass"
            aria-label="Image"
            title="Image"
            :disabled="!ready || locked || mode === 'source'"
            :tabindex="toolbarIndex === 9 ? 0 : -1"
            @focus="toolbarIndex = 9"
            @mousedown.prevent
            @click="openImage"
          >
            <Image class="size-4" />
          </button>
          <button
            type="button"
            data-slot="rich-text-tool"
            :class="toolClass"
            aria-label="Undo"
            title="Undo"
            :disabled="
              !ready || locked || mode === 'source' || !editor?.can().undo()
            "
            :tabindex="toolbarIndex === 10 ? 0 : -1"
            @focus="toolbarIndex = 10"
            @mousedown.prevent
            @click="run('undo')"
          >
            <svg
              class="size-4"
              viewBox="0 0 24 24"
              fill="none"
              stroke="currentColor"
              stroke-width="1.5"
              stroke-linecap="round"
              stroke-linejoin="round"
              aria-hidden="true"
              focusable="false"
            >
              <path d="m8 4-5 5 5 5M3 9h11a6 6 0 0 1 0 12h-3" />
            </svg>
          </button>
          <button
            type="button"
            data-slot="rich-text-tool"
            :class="toolClass"
            aria-label="Redo"
            title="Redo"
            :disabled="
              !ready || locked || mode === 'source' || !editor?.can().redo()
            "
            :tabindex="toolbarIndex === 11 ? 0 : -1"
            @focus="toolbarIndex = 11"
            @mousedown.prevent
            @click="run('redo')"
          >
            <svg
              class="size-4"
              viewBox="0 0 24 24"
              fill="none"
              stroke="currentColor"
              stroke-width="1.5"
              stroke-linecap="round"
              stroke-linejoin="round"
              aria-hidden="true"
              focusable="false"
            >
              <path d="m16 4 5 5-5 5m5-5H10a6 6 0 0 0 0 12h3" />
            </svg>
          </button>
        </div>
        <div
          class="ms-auto flex shrink-0 items-center gap-0.5 rounded-md bg-gray-200/50 p-0.5 dark:bg-gray-800/70"
          role="group"
          aria-label="Editing mode"
        >
          <button
            v-for="item in [
              { value: 'visual', label: 'Write' },
              { value: 'source', label: 'Source' }
            ]"
            :key="item.value"
            type="button"
            :disabled="disabled || !ready"
            :aria-pressed="mode === item.value"
            data-slot="rich-text-mode"
            class="min-h-8 cursor-pointer rounded px-2.5 text-xs font-medium text-gray-600 hover:text-gray-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 aria-pressed:bg-white aria-pressed:text-gray-950 aria-pressed:shadow-sm disabled:cursor-not-allowed dark:text-gray-400 dark:hover:text-white dark:aria-pressed:bg-gray-700 dark:aria-pressed:text-white dark:focus-visible:outline-white"
            @click="setMode(item.value)"
          >
            {{ item.label }}
          </button>
        </div>
      </div>
    </slot>
    <p
      v-if="warning"
      :id="`${fieldId}-warning`"
      data-slot="rich-text-warning"
      class="mx-5 mt-4 rounded-md bg-amber-50 p-3 text-sm/6 text-amber-900 dark:bg-amber-950/40 dark:text-amber-200"
      role="status"
    >
      {{ warning }}
    </p>
    <EditorContent v-show="ready && mode === 'visual'" :editor="editor" />
    <textarea
      ref="source"
      v-bind="nativeAttrs"
      :id="fieldId"
      :value="sourceValue"
      :disabled="disabled"
      :readonly="readonly"
      :required="required"
      :aria-label="accessibleLabel"
      :aria-describedby="description"
      :aria-invalid="validationError ? 'true' : attrs['aria-invalid']"
      :aria-hidden="ready && mode === 'visual' ? 'true' : undefined"
      :tabindex="ready && mode === 'visual' ? -1 : undefined"
      :placeholder="placeholder"
      :spellcheck="mode === 'source' ? false : attrs.spellcheck"
      data-slot="rich-text-source"
      :class="
        ready && mode === 'visual'
          ? 'sr-only'
          : 'block min-h-56 w-full min-w-0 resize-y rounded-b-[inherit] bg-transparent p-5 font-mono text-sm/7 outline-none wrap-anywhere'
      "
      @focus="
        (event) =>
          ready && mode === 'visual' ? focus() : attrs.onFocus?.(event)
      "
      @input="updateSource"
      @compositionstart="composing = true"
      @compositionend="
        (event) => {
          composing = false
          if (pendingExternal !== undefined) flushExternal()
          else updateSource(event)
        }
      "
      @invalid="invalid"
    />
    <p
      v-if="validationError"
      :id="`${fieldId}-error`"
      data-slot="rich-text-error"
      class="px-5 pb-3 text-sm text-red-600 dark:text-red-400"
      role="alert"
    >
      {{ validationError }}
    </p>
    <p
      data-slot="rich-text-status"
      aria-live="polite"
      aria-atomic="true"
      :class="
        status
          ? 'px-5 pb-3 text-sm text-gray-600 dark:text-gray-400'
          : 'sr-only'
      "
    >
      {{ status }}
    </p>
    <Popover
      :id="`${fieldId}-popover`"
      v-model:open="popupOpen"
      :anchor="popupAnchor"
      placement="bottom-start"
      :data-rich-text-owner="fieldId"
      role="dialog"
      :aria-label="popup === 'image' ? 'Edit image' : 'Edit link'"
      class="w-80 max-w-[calc(100vw-1rem)] rounded-xl p-4"
      @focusout="compositeBlur"
      @keydown.esc="
        (event) => {
          if (!event.defaultPrevented) {
            event.preventDefault()
            closePopup()
          }
        }
      "
    >
      <div
        v-if="popup === 'link'"
        class="grid gap-3"
        @keydown.enter="
          (event) => {
            if (event.target.tagName === 'INPUT' && !event.isComposing) {
              event.preventDefault()
              applyLink()
            }
          }
        "
      >
        <label :for="`${fieldId}-link`" class="text-sm font-medium">Link</label>
        <input
          ref="linkInput"
          :id="`${fieldId}-link`"
          v-model="linkUrl"
          :class="fieldClass"
          inputmode="url"
          autocomplete="off"
          placeholder="https://example.com"
          :aria-invalid="Boolean(popupError)"
          :aria-describedby="popupError ? `${fieldId}-popup-error` : undefined"
        />
        <div class="flex flex-wrap gap-2">
          <button type="button" :class="actionClass" @click="applyLink">
            Apply link</button
          ><button
            v-if="editor?.isActive('link')"
            type="button"
            class="min-h-10 cursor-pointer px-2 text-sm text-red-600"
            @click="removeLink"
          >
            Remove</button
          ><button
            type="button"
            class="ms-auto min-h-10 cursor-pointer px-2 text-sm"
            @click="closePopup()"
          >
            Cancel
          </button>
        </div>
      </div>
      <div
        v-else-if="popup === 'image'"
        class="grid gap-3"
        @keydown.enter="
          (event) => {
            if (
              event.target.tagName === 'INPUT' &&
              event.target.type !== 'file' &&
              !event.isComposing
            ) {
              event.preventDefault()
              applyImage()
            }
          }
        "
      >
        <label :for="`${fieldId}-image-url`" class="text-sm font-medium"
          >Image URL</label
        >
        <input
          ref="imageInput"
          :id="`${fieldId}-image-url`"
          v-model="imageUrl"
          :class="fieldClass"
          inputmode="url"
          autocomplete="off"
          placeholder="https://example.com/image.png"
          :aria-invalid="Boolean(popupError)"
          :aria-describedby="popupError ? `${fieldId}-popup-error` : undefined"
        />
        <label :for="`${fieldId}-image-alt`" class="text-sm font-medium"
          >Image description</label
        >
        <input
          :id="`${fieldId}-image-alt`"
          v-model="imageAlt"
          :class="fieldClass"
          placeholder="What does this image show?"
        />
        <p class="text-xs/5 text-gray-500 dark:text-gray-400">
          Describe meaningful images. Leave empty only for decoration.
        </p>
        <div class="flex flex-wrap gap-2">
          <button type="button" :class="actionClass" @click="applyImage">
            {{ selectedImage ? 'Update image' : 'Add image' }}</button
          ><button
            type="button"
            class="ms-auto min-h-10 cursor-pointer px-2 text-sm"
            @click="closePopup()"
          >
            Cancel
          </button>
        </div>
        <template v-if="upload"
          ><input
            ref="imageFile"
            type="file"
            accept="image/png,image/jpeg,image/gif,image/webp,image/avif"
            class="hidden"
            tabindex="-1"
            @change="
              (event) => {
                const files = event.target.files
                if (files?.length)
                  uploadFiles(
                    files,
                    selectionBookmark?.resolve(editor.state.doc).from
                  )
                event.target.value = ''
              }
            "
          /><button
            type="button"
            class="min-h-10 cursor-pointer rounded-md border border-gray-200 px-3 text-sm font-medium hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-900"
            :disabled="uploading"
            @click="imageFile?.click()"
          >
            Choose image file
          </button></template
        >
      </div>
      <p
        v-if="popupError"
        :id="`${fieldId}-popup-error`"
        class="mt-3 text-sm text-red-600 dark:text-red-400"
        role="alert"
      >
        {{ popupError }}
      </p>
    </Popover>
  </div>
</template>
jsx
import { EditorContent, useEditor } from '@tiptap/react'
import FileHandler from '@tiptap/extension-file-handler'
import { history } from '@tiptap/pm/history'
import StarterKit from '@tiptap/starter-kit'
import Image from '@tiptap/extension-image'
import Placeholder from '@tiptap/extension-placeholder'
import { Markdown } from '@tiptap/markdown'
import {
  forwardRef,
  useEffect,
  useId,
  useImperativeHandle,
  useRef,
  useState
} from 'react'
import { twMerge } from 'tailwind-merge'
import Popover from '../popover/Popover.jsx'
import Link from '../icons/Link.jsx'
import ImageIcon from '../icons/Image.jsx'
import {
  inspectMarkdown,
  inspectRichTextHtml,
  normalizeImageUrl,
  normalizeLinkUrl,
  preserveMarkdownEnvelope,
  roundTripMatches,
  sanitizeRichTextHtml,
  htmlRoundTripMatches
} from './rich-text.js'

const ROOT =
  'relative w-full min-w-0 rounded-xl border border-gray-200 bg-white text-gray-950 shadow-sm focus-within:border-gray-400 focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-gray-950/15 data-disabled:opacity-50 dark:border-gray-800 dark:bg-gray-950 dark:text-gray-100 dark:focus-within:border-gray-600 dark:focus-within:outline-white/20'
const CONTENT =
  'min-h-56 w-full min-w-0 px-5 py-5 text-base/7 outline-none wrap-anywhere *:first:mt-0 *:last:mb-0 [&_p]:my-3 [&_h1]:mt-7 [&_h1]:mb-3 [&_h1]:text-3xl [&_h1]:font-semibold [&_h1]:tracking-tight [&_h2]:mt-6 [&_h2]:mb-3 [&_h2]:text-2xl [&_h2]:font-semibold [&_h2]:tracking-tight [&_h3]:mt-5 [&_h3]:mb-2 [&_h3]:text-xl [&_h3]:font-semibold [&_h4]:mt-4 [&_h4]:font-semibold [&_h5]:font-semibold [&_h6]:font-semibold [&_strong]:font-semibold [&_a]:text-blue-700 [&_a]:underline [&_a]:underline-offset-3 [&_ul]:my-3 [&_ul]:list-disc [&_ul]:ps-6 [&_ol]:my-3 [&_ol]:list-decimal [&_ol]:ps-6 [&_li]:my-1 [&_li>p]:my-1 [&_blockquote]:my-4 [&_blockquote]:border-s-2 [&_blockquote]:border-gray-300 [&_blockquote]:ps-4 [&_blockquote]:text-gray-600 [&_pre]:my-4 [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:bg-gray-100 [&_pre]:p-4 [&_pre]:text-sm [&_code]:rounded [&_code]:bg-gray-100 [&_code]:px-1 [&_code]:font-mono [&_code]:text-[0.9em] [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_img]:my-4 [&_img]:max-h-96 [&_img]:max-w-full [&_img]:rounded-lg [&_img]:object-contain [&_hr]:my-6 [&_hr]:border-gray-200 [&_.ProseMirror-selectednode]:outline-2 [&_.ProseMirror-selectednode]:outline-blue-500 [&_p.is-editor-empty:first-child]:before:pointer-events-none [&_p.is-editor-empty:first-child]:before:float-start [&_p.is-editor-empty:first-child]:before:h-0 [&_p.is-editor-empty:first-child]:before:text-gray-500 [&_p.is-editor-empty:first-child]:before:content-[attr(data-placeholder)] dark:[&_a]:text-blue-400 dark:[&_blockquote]:border-gray-600 dark:[&_blockquote]:text-gray-400 dark:[&_pre]:bg-gray-900 dark:[&_code]:bg-gray-900 dark:[&_hr]:border-gray-800'
const TOOL =
  'inline-flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-md text-sm text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 aria-pressed:bg-gray-100 aria-pressed:text-gray-950 disabled:cursor-not-allowed disabled:opacity-35 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-white dark:aria-pressed:bg-gray-800 dark:aria-pressed:text-white dark:focus-visible:outline-white motion-reduce:transition-none'
const FIELD =
  'min-h-10 w-full min-w-0 rounded-md border border-gray-300 bg-transparent px-3 py-2 text-sm outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 dark:border-gray-700 dark:focus-visible:outline-white'
const ACTION =
  'min-h-10 cursor-pointer rounded-md bg-gray-950 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-white dark:text-gray-950 dark:hover:bg-gray-200'
const COMMANDS = [
  ['Heading', 'H₂', 'heading', 'toggleHeading', { level: 2 }],
  ['Bold', 'B', 'bold', 'toggleBold', undefined, 'font-bold'],
  ['Italic', 'I', 'italic', 'toggleItalic', undefined, 'font-serif italic'],
  ['Strikethrough', 'S', 'strike', 'toggleStrike', undefined, 'line-through'],
  ['Bullet list', '• ≡', 'bulletList', 'toggleBulletList'],
  ['Numbered list', '1. ≡', 'orderedList', 'toggleOrderedList'],
  ['Quote', '❞', 'blockquote', 'toggleBlockquote'],
  [
    'Code',
    '</>',
    'codeBlock',
    'toggleCodeBlock',
    undefined,
    'font-mono text-xs'
  ]
]

function ToolGlyph({ name, children }) {
  const path = {
    'Bullet list': 'M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01',
    'Numbered list':
      'M10 6h10M10 12h10M10 18h10M3 5l1-1v5M3 9h2M3 15a1.5 1.5 0 0 1 3 0c0 1-3 2-3 4h3',
    Quote: 'M10 6H4v6h5c0 3-2 5-5 6M20 6h-6v6h5c0 3-2 5-5 6',
    Code: 'm8 7-5 5 5 5m8-10 5 5-5 5m-3-14-2 18',
    Undo: 'm8 4-5 5 5 5M3 9h11a6 6 0 0 1 0 12h-3',
    Redo: 'm16 4 5 5-5 5m5-5H10a6 6 0 0 0 0 12h3'
  }[name]
  return path ? (
    <svg
      className="size-4"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.5"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      focusable="false"
    >
      <path d={path} />
    </svg>
  ) : (
    <span>{children}</span>
  )
}

const RichText = forwardRef(function RichText(
  {
    value = '',
    onValueChange,
    format = 'html',
    id,
    name,
    form,
    required = false,
    disabled = false,
    readOnly = false,
    maxLength,
    placeholder = 'Start writing…',
    upload,
    renderToolbar,
    className,
    style,
    onBlur,
    onFocus,
    onInput,
    onModeChange,
    ...attributes
  },
  ref
) {
  const generatedId = useId().replace(/[^a-zA-Z0-9_-]/g, '')
  const fieldId = id ?? `klean-rich-text-${generatedId}`
  const root = useRef(null)
  const textarea = useRef(null)
  const editorRef = useRef(null)
  const linkPopover = useRef(null)
  const imagePopover = useRef(null)
  const linkButton = useRef(null)
  const imageButton = useRef(null)
  const linkInput = useRef(null)
  const imageInput = useRef(null)
  const fileInput = useRef(null)
  const toolbar = useRef(null)
  const initial = useRef(String(value ?? ''))
  const syncing = useRef(false)
  const alive = useRef(false)
  const jobs = useRef(new Set())
  const selection = useRef(null)
  const composing = useRef(false)
  const pendingExternal = useRef(undefined)
  const lastRenderedValue = useRef(undefined)
  const [source, setSource] = useState(String(value ?? ''))
  const sourceRef = useRef(source)
  const [mode, setModeState] = useState('source')
  const modeRef = useRef(mode)
  const [revision, setRevision] = useState(0)
  const [compatibility, setCompatibility] = useState('')
  const [error, setError] = useState('')
  const [message, setMessage] = useState('')
  const [pending, setPending] = useState(0)
  const [linkUrl, setLinkUrl] = useState('')
  const [linkError, setLinkError] = useState('')
  const [imageUrl, setImageUrl] = useState('')
  const [imageAlt, setImageAlt] = useState('')
  const [imageError, setImageError] = useState('')
  const [selectedImage, setSelectedImage] = useState(false)
  const [toolbarIndex, setToolbarIndex] = useState(0)
  const latest = useRef(null)
  latest.current = {
    value,
    onValueChange,
    format,
    required,
    disabled,
    readOnly,
    maxLength,
    placeholder,
    upload,
    attributes,
    onBlur,
    onFocus,
    onInput,
    onModeChange
  }

  function assignSource(next, emit = false) {
    sourceRef.current = next
    setSource(next)
    if (textarea.current) textarea.current.value = next
    if (emit) latest.current.onValueChange?.(next)
  }

  function cancelUploads() {
    if (jobs.current.size) setMessage('')
    for (const job of jobs.current) job.controller.abort()
    jobs.current.clear()
    setPending(0)
  }

  function changeMode(next) {
    if (modeRef.current === next) return
    modeRef.current = next
    setModeState(next)
    latest.current.onModeChange?.(next)
  }

  function loadSource(next, current = editorRef.current) {
    if (!current) return false
    if (next === lastRenderedValue.current) {
      setCompatibility('')
      return true
    }
    const inspection =
      latest.current.format === 'markdown'
        ? inspectMarkdown(next)
        : inspectRichTextHtml(next)
    if (!inspection.supported) {
      setCompatibility(
        `Keep editing the source to preserve ${inspection.issues.map((issue) => issue.label).join(', ')}.`
      )
      changeMode('source')
      return false
    }
    syncing.current = true
    lastRenderedValue.current = undefined
    try {
      current.commands.setContent(
        latest.current.format === 'markdown'
          ? next
          : sanitizeRichTextHtml(next),
        { contentType: latest.current.format, emitUpdate: false }
      )
      if (
        latest.current.format === 'html' &&
        !htmlRoundTripMatches(next, current.getHTML())
      ) {
        setCompatibility(
          'This content needs source editing so none of its formatting is lost.'
        )
        changeMode('source')
        return false
      }
      if (
        latest.current.format === 'markdown' &&
        !roundTripMatches(next, current.getMarkdown(), (content) =>
          current.markdown.parse(content)
        )
      ) {
        setCompatibility(
          'This content needs source editing so none of its formatting is lost.'
        )
        changeMode('source')
        return false
      }
      const historyKey = history().spec.key
      const historyPlugin = historyKey.get(current.state)
      if (historyPlugin) {
        current.unregisterPlugin(historyKey)
        current.registerPlugin(historyPlugin)
      }
      lastRenderedValue.current = next
      setCompatibility('')
      return true
    } catch {
      setCompatibility(
        'This content could not be opened safely. Your source is still intact.'
      )
      changeMode('source')
      return false
    } finally {
      syncing.current = false
    }
  }

  function focusEditor() {
    if (latest.current.disabled) return
    if (modeRef.current === 'visual')
      editorRef.current?.view.dom.focus({ preventScroll: true })
    else textarea.current?.focus({ preventScroll: true })
  }

  function setMode(next) {
    if (latest.current.disabled || !['visual', 'source'].includes(next))
      return false
    if (next === modeRef.current) return true
    cancelUploads()
    linkPopover.current?.close({ restoreFocus: false })
    imagePopover.current?.close({ restoreFocus: false })
    if (next === 'visual' && !loadSource(sourceRef.current)) return false
    changeMode(next)
    requestAnimationFrame(focusEditor)
    return true
  }

  function validate() {
    const current = latest.current
    let empty = !sourceRef.current.trim()
    if (modeRef.current === 'visual' && editorRef.current)
      empty = editorRef.current.isEmpty
    else if (current.format === 'html' && typeof document !== 'undefined') {
      const template = document.createElement('template')
      template.innerHTML = sanitizeRichTextHtml(sourceRef.current)
      empty =
        !template.content.textContent.replace(/[\s\u200B-\u200D\uFEFF]/g, '') &&
        !template.content.querySelector('img[src],hr')
    }
    const message =
      current.disabled || current.readOnly
        ? ''
        : current.required && empty
          ? 'Please fill out this field.'
          : Number.isFinite(Number(current.maxLength)) &&
              current.maxLength != null &&
              sourceRef.current.length > Number(current.maxLength)
            ? `Use ${current.maxLength} characters or fewer, including formatting.`
            : ''
    textarea.current?.setCustomValidity(message)
    return message
  }

  function command(method, argument) {
    if (
      latest.current.disabled ||
      latest.current.readOnly ||
      modeRef.current !== 'visual'
    )
      return
    editorRef.current?.chain().focus()[method](argument).run()
  }

  function saveSelection() {
    selection.current = editorRef.current?.state.selection.getBookmark()
  }

  function restoreSelection() {
    const current = editorRef.current
    if (!current || !selection.current) return
    try {
      current.view.dispatch(
        current.state.tr.setSelection(
          selection.current.resolve(current.state.doc)
        )
      )
    } catch {
      /* The selected content may have been replaced. */
    }
  }

  function openLink(event) {
    if (
      !editorRef.current ||
      latest.current.disabled ||
      latest.current.readOnly ||
      modeRef.current !== 'visual'
    )
      return
    saveSelection()
    setLinkUrl(editorRef.current.getAttributes('link').href ?? '')
    setLinkError('')
    linkPopover.current?.open(
      event?.currentTarget ?? linkButton.current ?? editorRef.current.view.dom
    )
    requestAnimationFrame(() => linkInput.current?.focus())
  }

  function openImage(event) {
    if (
      !editorRef.current ||
      latest.current.disabled ||
      latest.current.readOnly ||
      modeRef.current !== 'visual'
    )
      return
    saveSelection()
    const isImage = editorRef.current.isActive('image')
    setSelectedImage(isImage)
    setImageUrl(isImage ? editorRef.current.getAttributes('image').src : '')
    setImageAlt(
      isImage ? (editorRef.current.getAttributes('image').alt ?? '') : ''
    )
    setImageError('')
    imagePopover.current?.open(
      event?.currentTarget ?? imageButton.current ?? editorRef.current.view.dom
    )
    requestAnimationFrame(() => imageInput.current?.focus())
  }

  function applyLink(remove = false) {
    if (latest.current.disabled || latest.current.readOnly) return
    const url = normalizeLinkUrl(linkUrl)
    if (!remove && !url) {
      setLinkError('Enter a safe web, email, phone, or relative URL.')
      return
    }
    restoreSelection()
    if (remove)
      editorRef.current
        ?.chain()
        .focus()
        .extendMarkRange('link')
        .unsetLink()
        .run()
    else if (
      editorRef.current.state.selection.empty &&
      !editorRef.current.isActive('link')
    )
      editorRef.current
        .chain()
        .focus()
        .insertContent({
          type: 'text',
          text: linkUrl.trim(),
          marks: [{ type: 'link', attrs: { href: url } }]
        })
        .run()
    else
      editorRef.current
        ?.chain()
        .focus()
        .extendMarkRange('link')
        .setLink({ href: url })
        .run()
    linkPopover.current?.close({ restoreFocus: false })
  }

  function applyImage() {
    if (latest.current.disabled || latest.current.readOnly) return
    const src = normalizeImageUrl(imageUrl)
    if (!src) {
      setImageError('Enter a safe image URL.')
      return
    }
    restoreSelection()
    editorRef.current
      ?.chain()
      .focus()
      .setImage({ src, alt: imageAlt.trim() })
      .run()
    imagePopover.current?.close({ restoreFocus: false })
  }

  async function uploadFiles(
    files,
    position = editorRef.current?.state.selection.from
  ) {
    if (
      latest.current.disabled ||
      latest.current.readOnly ||
      modeRef.current !== 'visual' ||
      !editorRef.current
    )
      return
    if (!latest.current.upload) {
      setMessage('Choose an image URL. File uploads are not configured.')
      return
    }
    const current = editorRef.current
    const job = { controller: new AbortController(), position }
    jobs.current.add(job)
    setPending(jobs.current.size)
    setMessage('Uploading image…')
    imagePopover.current?.close({ restoreFocus: false })
    try {
      for (const file of files) {
        if (!/^image\/(avif|gif|jpeg|png|webp)$/.test(file.type))
          throw new Error('Choose a PNG, JPEG, GIF, WebP or AVIF image.')
        const result = await latest.current.upload(file, {
          signal: job.controller.signal
        })
        if (
          !alive.current ||
          !jobs.current.has(job) ||
          job.controller.signal.aborted ||
          latest.current.disabled ||
          latest.current.readOnly ||
          modeRef.current !== 'visual' ||
          editorRef.current !== current
        )
          return
        const src = normalizeImageUrl(result?.src)
        if (!src) throw new Error('The upload did not return a safe image URL.')
        current.commands.insertContentAt(
          Math.min(job.position, current.state.doc.content.size),
          {
            type: 'image',
            attrs: { src, alt: result.alt ?? '', title: result.title ?? null }
          },
          { updateSelection: false }
        )
      }
      setMessage('Image added. Select it to edit its description.')
    } catch (cause) {
      if (
        alive.current &&
        jobs.current.has(job) &&
        !job.controller.signal.aborted
      )
        setMessage(
          cause?.message || 'The image could not be uploaded. Try again.'
        )
    } finally {
      jobs.current.delete(job)
      if (alive.current) setPending(jobs.current.size)
    }
  }

  function focusToolbar() {
    toolbar.current?.querySelector('button[tabindex="0"]')?.focus()
  }
  function toolbarKeys(event) {
    if (event.key === 'Escape') {
      event.preventDefault()
      focusEditor()
      return
    }
    if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return
    const buttons = [
      ...event.currentTarget.querySelectorAll('button:not(:disabled)')
    ]
    const index = buttons.indexOf(event.target)
    if (index < 0) return
    event.preventDefault()
    const rtl = getComputedStyle(event.currentTarget).direction === 'rtl'
    const direction = (event.key === 'ArrowLeft' ? -1 : 1) * (rtl ? -1 : 1)
    const next =
      event.key === 'Home'
        ? 0
        : event.key === 'End'
          ? buttons.length - 1
          : (index + direction + buttons.length) % buttons.length
    setToolbarIndex(
      [...event.currentTarget.querySelectorAll('button')].indexOf(buttons[next])
    )
    buttons[next].focus()
  }

  function commitEditor(current = editorRef.current) {
    if (
      !current ||
      syncing.current ||
      composing.current ||
      pendingExternal.current !== undefined ||
      modeRef.current !== 'visual' ||
      latest.current.disabled ||
      latest.current.readOnly
    )
      return
    const next = current.isEmpty
      ? ''
      : latest.current.format === 'markdown'
        ? preserveMarkdownEnvelope(current.getMarkdown(), sourceRef.current)
        : current.getHTML()
    if (next === sourceRef.current) return
    lastRenderedValue.current = next
    assignSource(next, true)
    if (!validate()) setError('')
  }

  function replaceValue(next) {
    if (next === sourceRef.current) return
    if (composing.current || editorRef.current?.view.composing) {
      pendingExternal.current = next
      return
    }
    cancelUploads()
    assignSource(next)
    setError('')
    linkPopover.current?.close({ restoreFocus: false })
    imagePopover.current?.close({ restoreFocus: false })
    if (modeRef.current === 'visual') loadSource(next)
  }

  function flushExternal() {
    if (pendingExternal.current === undefined) return
    const next = pendingExternal.current
    pendingExternal.current = undefined
    replaceValue(next)
  }

  function updateSource(event) {
    if (latest.current.disabled || latest.current.readOnly) return
    setSource(event.target.value)
    if (event.nativeEvent?.isComposing || composing.current) return
    cancelUploads()
    assignSource(event.target.value, true)
    setCompatibility('')
    if (!validate()) setError('')
    latest.current.onInput?.(event)
  }

  const editor = useEditor({
    immediatelyRender: false,
    content: '',
    editable: !latest.current.disabled && !latest.current.readOnly,
    extensions: [
      StarterKit.configure({
        underline: false,
        link: {
          openOnClick: false,
          autolink: true,
          defaultProtocol: 'https',
          isAllowedUri: (url, context) =>
            context.defaultValidate(url) && normalizeLinkUrl(url) !== null,
          HTMLAttributes: { target: null, rel: 'noopener noreferrer' }
        }
      }),
      Image.configure({ allowBase64: false }),
      Placeholder.configure({
        placeholder: () => latest.current.placeholder
      }),
      Markdown,
      FileHandler.configure({
        allowedMimeTypes: [
          'image/png',
          'image/jpeg',
          'image/gif',
          'image/webp',
          'image/avif'
        ],
        onPaste: (current, files) =>
          uploadFiles(files, current.state.selection.from),
        onDrop: (_current, files, position) => uploadFiles(files, position)
      })
    ],
    editorProps: {
      transformPastedHTML: (html) => sanitizeRichTextHtml(html),
      handleKeyDown: (_view, event) => {
        if (event.altKey && event.key === 'F10') {
          event.preventDefault()
          focusToolbar()
          return true
        }
        if (
          !event.isComposing &&
          (event.ctrlKey || event.metaKey) &&
          event.key.toLowerCase() === 'k'
        ) {
          event.preventDefault()
          openLink()
          return true
        }
        return false
      },
      handleDOMEvents: {
        focus: (_view, event) => {
          latest.current.onFocus?.(event)
          return false
        },
        compositionstart: () => {
          composing.current = true
          return false
        },
        compositionend: () => {
          composing.current = false
          queueMicrotask(() => {
            if (alive.current) {
              if (pendingExternal.current !== undefined) flushExternal()
              else commitEditor()
            }
          })
          return false
        }
      }
    },
    onCreate: ({ editor: current }) => {
      alive.current = true
      editorRef.current = current
      if (loadSource(sourceRef.current, current)) changeMode('visual')
    },
    onUpdate: ({ editor: current }) => commitEditor(current),
    onTransaction: ({ transaction }) => {
      for (const job of jobs.current)
        job.position = transaction.mapping.map(job.position, 1)
      if (selection.current)
        selection.current = selection.current.map(transaction.mapping)
      setRevision((value) => value + 1)
    }
  })
  useEffect(() => {
    alive.current = true
    return () => {
      alive.current = false
      cancelUploads()
      editorRef.current = null
    }
  }, [])

  useEffect(() => {
    replaceValue(String(value ?? ''))
  }, [value])

  const previousFormat = useRef(format)
  useEffect(() => {
    if (previousFormat.current === format) return
    previousFormat.current = format
    cancelUploads()
    lastRenderedValue.current = undefined
    loadSource(sourceRef.current)
  }, [format])

  useEffect(() => {
    if (disabled || readOnly) {
      cancelUploads()
      linkPopover.current?.close({ restoreFocus: false })
      imagePopover.current?.close({ restoreFocus: false })
    }
    editor?.setEditable(!disabled && !readOnly && mode === 'visual', false)
  }, [disabled, readOnly, editor, mode])

  useEffect(() => {
    if (!editor) return
    const aria = Object.fromEntries(
      Object.entries(attributes)
        .filter(
          ([key, value]) =>
            (key.startsWith('aria-') ||
              ['title', 'lang', 'dir', 'spellcheck'].includes(key)) &&
            value != null
        )
        .map(([key, value]) => [key, String(value)])
    )
    editor.setOptions({
      editorProps: {
        ...editor.options.editorProps,
        attributes: () =>
          Object.fromEntries(
            Object.entries({
              ...aria,
              id: `${fieldId}-editor`,
              class: CONTENT,
              role: 'textbox',
              'aria-multiline': 'true',
              'aria-required': String(required),
              'aria-disabled': String(disabled),
              'aria-readonly': String(readOnly),
              tabindex: disabled ? '-1' : '0',
              spellcheck: attributes.spellCheck ?? 'true',
              'aria-invalid': String(
                Boolean(error) ||
                  attributes['aria-invalid'] === true ||
                  attributes['aria-invalid'] === 'true'
              ),
              'aria-label':
                aria['aria-label'] ??
                (aria['aria-labelledby']
                  ? undefined
                  : [...(textarea.current?.labels ?? [])]
                      .map((label) => label.textContent.trim())
                      .join(' ') || 'Rich text'),
              'aria-describedby':
                [
                  aria['aria-describedby'],
                  compatibility ? `${fieldId}-warning` : '',
                  error ? `${fieldId}-error` : ''
                ]
                  .filter(Boolean)
                  .join(' ') || undefined,
              'data-slot': 'rich-text-content'
            }).filter(([, value]) => value != null)
          )
      }
    })
    validate()
  }, [
    editor,
    source,
    mode,
    required,
    disabled,
    readOnly,
    maxLength,
    error,
    compatibility,
    fieldId,
    attributes['aria-label'],
    attributes['aria-labelledby'],
    attributes['aria-describedby'],
    attributes['aria-invalid'],
    attributes.dir,
    attributes.lang,
    attributes.spellCheck,
    revision
  ])

  useEffect(() => {
    const owner = textarea.current?.form
    if (!owner) return
    const reset = (event) =>
      queueMicrotask(() => {
        if (!alive.current || event.defaultPrevented) return
        cancelUploads()
        assignSource(initial.current, true)
        setError('')
        setCompatibility('')
        setMessage('')
        linkPopover.current?.close({ restoreFocus: false })
        imagePopover.current?.close({ restoreFocus: false })
        if (loadSource(initial.current)) changeMode('visual')
        validate()
      })
    owner.addEventListener('reset', reset)
    return () => owner.removeEventListener('reset', reset)
  }, [form, editor])

  useEffect(() => {
    const buttons = [...(toolbar.current?.querySelectorAll('button') ?? [])]
    if (!buttons[toolbarIndex] || buttons[toolbarIndex].disabled)
      setToolbarIndex(buttons.findIndex((button) => !button.disabled))
  }, [revision, mode, disabled, readOnly, editor, toolbarIndex])

  useImperativeHandle(ref, () => ({
    editor,
    focus: focusEditor,
    setMode,
    getMode: () => modeRef.current,
    checkValidity: () => {
      validate()
      return textarea.current?.checkValidity() ?? true
    },
    reportValidity: () => {
      validate()
      return textarea.current?.reportValidity() ?? true
    }
  }))

  function compositeBlur(event) {
    if (root.current?.contains(event.relatedTarget)) return
    queueMicrotask(() => {
      if (
        alive.current &&
        !root.current?.contains(root.current?.getRootNode().activeElement)
      )
        latest.current.onBlur?.(event)
    })
  }

  function closePopup(popover) {
    restoreSelection()
    popover.current?.close({ restoreFocus: false })
    focusEditor()
  }
  function popupKeys(event, apply, popover) {
    if (event.key === 'Escape' && !event.defaultPrevented) {
      event.preventDefault()
      closePopup(popover)
    } else if (
      event.key === 'Enter' &&
      !event.nativeEvent.isComposing &&
      event.target.tagName === 'INPUT' &&
      event.target.type !== 'file'
    ) {
      event.preventDefault()
      apply()
    }
  }

  const inactive = !editor || mode !== 'visual' || disabled || readOnly
  const context = { editor, mode, setMode, openLink, openImage }
  const description =
    [
      attributes['aria-describedby'],
      compatibility && `${fieldId}-warning`,
      error && `${fieldId}-error`
    ]
      .filter(Boolean)
      .join(' ') || undefined
  return (
    <div
      ref={root}
      data-slot="rich-text"
      data-mode={mode}
      data-disabled={disabled || undefined}
      data-readonly={readOnly || undefined}
      style={style}
      className={twMerge(ROOT, className)}
      onBlur={compositeBlur}
    >
      {renderToolbar ? (
        renderToolbar(context)
      ) : (
        <div
          data-slot="rich-text-toolbar"
          className="flex flex-wrap items-center gap-2 rounded-t-[inherit] bg-gray-50/70 p-2 dark:bg-gray-900/50"
        >
          <div
            ref={toolbar}
            role="toolbar"
            aria-label="Text formatting"
            className="flex min-w-0 flex-wrap items-center gap-0.5"
            onKeyDown={toolbarKeys}
          >
            {COMMANDS.map(
              ([label, glyph, active, method, argument, toolClass], index) => (
                <button
                  key={label}
                  type="button"
                  data-slot="rich-text-tool"
                  className={twMerge(TOOL, toolClass)}
                  aria-label={label}
                  title={label}
                  aria-pressed={editor?.isActive(active, argument) ?? false}
                  disabled={inactive}
                  tabIndex={toolbarIndex === index ? 0 : -1}
                  onFocus={() => setToolbarIndex(index)}
                  onMouseDown={(event) => event.preventDefault()}
                  onClick={() => command(method, argument)}
                >
                  <ToolGlyph name={label}>{glyph}</ToolGlyph>
                </button>
              )
            )}
            <button
              ref={linkButton}
              type="button"
              data-action="link"
              data-slot="rich-text-tool"
              className={TOOL}
              aria-label="Link"
              title="Link · Ctrl/⌘ K"
              aria-pressed={editor?.isActive('link') ?? false}
              disabled={inactive}
              tabIndex={toolbarIndex === 8 ? 0 : -1}
              onFocus={() => setToolbarIndex(8)}
              onMouseDown={(event) => event.preventDefault()}
              onClick={openLink}
            >
              <Link className="size-4" />
            </button>
            <button
              ref={imageButton}
              type="button"
              data-action="image"
              data-slot="rich-text-tool"
              className={TOOL}
              aria-label="Image"
              title="Image"
              disabled={inactive}
              tabIndex={toolbarIndex === 9 ? 0 : -1}
              onFocus={() => setToolbarIndex(9)}
              onMouseDown={(event) => event.preventDefault()}
              onClick={openImage}
            >
              <ImageIcon className="size-4" />
            </button>
            <button
              type="button"
              data-slot="rich-text-tool"
              className={TOOL}
              aria-label="Undo"
              title="Undo"
              disabled={inactive || !editor?.can().undo()}
              tabIndex={toolbarIndex === 10 ? 0 : -1}
              onFocus={() => setToolbarIndex(10)}
              onMouseDown={(event) => event.preventDefault()}
              onClick={() => command('undo')}
            >
              <ToolGlyph name="Undo" />
            </button>
            <button
              type="button"
              data-slot="rich-text-tool"
              className={TOOL}
              aria-label="Redo"
              title="Redo"
              disabled={inactive || !editor?.can().redo()}
              tabIndex={toolbarIndex === 11 ? 0 : -1}
              onFocus={() => setToolbarIndex(11)}
              onMouseDown={(event) => event.preventDefault()}
              onClick={() => command('redo')}
            >
              <ToolGlyph name="Redo" />
            </button>
          </div>
          <div
            className="ms-auto flex shrink-0 items-center gap-0.5 rounded-md bg-gray-200/50 p-0.5 dark:bg-gray-800/70"
            role="group"
            aria-label="Editing mode"
          >
            {[
              { value: 'visual', label: 'Write' },
              { value: 'source', label: 'Source' }
            ].map((item) => (
              <button
                key={item.value}
                type="button"
                disabled={disabled || !editor}
                aria-pressed={mode === item.value}
                data-slot="rich-text-mode"
                className="min-h-8 cursor-pointer rounded px-2.5 text-xs font-medium text-gray-600 hover:text-gray-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 aria-pressed:bg-white aria-pressed:text-gray-950 aria-pressed:shadow-sm disabled:cursor-not-allowed dark:text-gray-400 dark:hover:text-white dark:aria-pressed:bg-gray-700 dark:aria-pressed:text-white dark:focus-visible:outline-white"
                onClick={() => setMode(item.value)}
              >
                {item.label}
              </button>
            ))}
          </div>
        </div>
      )}
      {compatibility && (
        <p
          id={`${fieldId}-warning`}
          data-slot="rich-text-warning"
          className="mx-5 mt-4 rounded-md bg-amber-50 p-3 text-sm/6 text-amber-900 dark:bg-amber-950/40 dark:text-amber-200"
          role="status"
        >
          {compatibility}
        </p>
      )}
      <div hidden={mode !== 'visual'}>
        <EditorContent editor={editor} />
      </div>
      <textarea
        {...attributes}
        ref={textarea}
        id={fieldId}
        name={name}
        form={form}
        value={source}
        required={required}
        disabled={disabled}
        readOnly={readOnly}
        placeholder={placeholder}
        data-slot="rich-text-source"
        aria-hidden={mode === 'visual' ? 'true' : undefined}
        aria-describedby={description}
        aria-invalid={error ? 'true' : attributes['aria-invalid']}
        tabIndex={mode === 'visual' ? -1 : undefined}
        spellCheck={mode === 'source' ? false : attributes.spellCheck}
        className={
          mode === 'visual'
            ? 'sr-only'
            : 'block min-h-56 w-full min-w-0 resize-y rounded-b-[inherit] bg-transparent p-5 font-mono text-sm/7 outline-none wrap-anywhere'
        }
        onFocus={(event) => {
          if (modeRef.current === 'visual') focusEditor()
          else onFocus?.(event)
        }}
        onChange={updateSource}
        onCompositionStart={() => {
          composing.current = true
        }}
        onCompositionEnd={(event) => {
          composing.current = false
          if (pendingExternal.current !== undefined) flushExternal()
          else updateSource(event)
        }}
        onInvalid={(event) => {
          event.preventDefault()
          setError(
            validate() ||
              textarea.current?.validationMessage ||
              'Check this field.'
          )
          focusEditor()
        }}
      />
      {error && (
        <p
          id={`${fieldId}-error`}
          data-slot="rich-text-error"
          className="px-5 pb-3 text-sm text-red-600 dark:text-red-400"
          role="alert"
        >
          {error}
        </p>
      )}
      <p
        data-slot="rich-text-status"
        aria-live="polite"
        aria-atomic="true"
        className={
          message
            ? 'px-5 pb-3 text-sm text-gray-600 dark:text-gray-400'
            : 'sr-only'
        }
      >
        {message}
      </p>
      <Popover
        ref={linkPopover}
        anchor={linkButton.current}
        role="dialog"
        aria-label="Edit link"
        data-rich-text-owner={fieldId}
        className="w-80 max-w-[calc(100vw-1rem)] rounded-xl p-4"
        onKeyDown={(event) => popupKeys(event, () => applyLink(), linkPopover)}
      >
        <div className="grid gap-3">
          <label htmlFor={`${fieldId}-link`} className="text-sm font-medium">
            Link
          </label>
          <input
            ref={linkInput}
            id={`${fieldId}-link`}
            inputMode="url"
            autoComplete="off"
            className={FIELD}
            value={linkUrl}
            onChange={(event) => setLinkUrl(event.target.value)}
            placeholder="https://example.com"
            aria-invalid={!!linkError}
            aria-describedby={linkError ? `${fieldId}-link-error` : undefined}
          />
          <div className="flex flex-wrap gap-2">
            <button
              type="button"
              className={ACTION}
              onClick={() => applyLink()}
            >
              Apply link
            </button>
            {editor?.isActive('link') && (
              <button
                type="button"
                className="min-h-10 cursor-pointer px-2 text-sm text-red-600"
                onClick={() => applyLink(true)}
              >
                Remove
              </button>
            )}
            <button
              type="button"
              className="ms-auto min-h-10 cursor-pointer px-2 text-sm"
              onClick={() => closePopup(linkPopover)}
            >
              Cancel
            </button>
          </div>
          {linkError && (
            <p
              id={`${fieldId}-link-error`}
              className="text-sm text-red-600 dark:text-red-400"
              role="alert"
            >
              {linkError}
            </p>
          )}
        </div>
      </Popover>
      <Popover
        ref={imagePopover}
        anchor={imageButton.current}
        role="dialog"
        aria-label="Edit image"
        data-rich-text-owner={fieldId}
        className="w-80 max-w-[calc(100vw-1rem)] rounded-xl p-4"
        onKeyDown={(event) => popupKeys(event, applyImage, imagePopover)}
      >
        <div className="grid gap-3">
          <label
            htmlFor={`${fieldId}-image-url`}
            className="text-sm font-medium"
          >
            Image URL
          </label>
          <input
            ref={imageInput}
            id={`${fieldId}-image-url`}
            inputMode="url"
            autoComplete="off"
            className={FIELD}
            value={imageUrl}
            onChange={(event) => setImageUrl(event.target.value)}
            placeholder="https://example.com/image.png"
            aria-invalid={!!imageError}
            aria-describedby={imageError ? `${fieldId}-image-error` : undefined}
          />
          <label
            htmlFor={`${fieldId}-image-alt`}
            className="text-sm font-medium"
          >
            Image description
          </label>
          <input
            id={`${fieldId}-image-alt`}
            className={FIELD}
            value={imageAlt}
            onChange={(event) => setImageAlt(event.target.value)}
            placeholder="What does this image show?"
          />
          <p className="text-xs/5 text-gray-500 dark:text-gray-400">
            Describe meaningful images. Leave empty only for decoration.
          </p>
          <div className="flex flex-wrap gap-2">
            <button type="button" className={ACTION} onClick={applyImage}>
              {selectedImage ? 'Update image' : 'Add image'}
            </button>
            <button
              type="button"
              className="ms-auto min-h-10 cursor-pointer px-2 text-sm"
              onClick={() => closePopup(imagePopover)}
            >
              Cancel
            </button>
          </div>
          {upload && (
            <>
              <input
                ref={fileInput}
                type="file"
                accept="image/png,image/jpeg,image/gif,image/webp,image/avif"
                className="hidden"
                tabIndex={-1}
                disabled={disabled || readOnly}
                onChange={(event) => {
                  const files = [...event.target.files]
                  event.target.value = ''
                  restoreSelection()
                  void uploadFiles(files)
                }}
              />
              <button
                type="button"
                className="min-h-10 cursor-pointer rounded-md border border-gray-200 px-3 text-sm font-medium hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-900"
                disabled={pending > 0}
                onClick={() => fileInput.current?.click()}
              >
                Choose image file
              </button>
            </>
          )}
          {imageError && (
            <p
              id={`${fieldId}-image-error`}
              className="text-sm text-red-600 dark:text-red-400"
              role="alert"
            >
              {imageError}
            </p>
          )}
        </div>
      </Popover>
    </div>
  )
})

export default RichText
svelte
<script>
  import { onMount, tick, untrack } from "svelte";
  import { Editor } from "@tiptap/core";
  import { history } from "@tiptap/pm/history";
  import FileHandler from "@tiptap/extension-file-handler";
  import StarterKit from "@tiptap/starter-kit";
  import ImageExtension from "@tiptap/extension-image";
  import Placeholder from "@tiptap/extension-placeholder";
  import { Markdown } from "@tiptap/markdown";
  import { twMerge } from "tailwind-merge";
  import Popover from "../popover/Popover.svelte";
  import Link from "../icons/Link.svelte";
  import Image from "../icons/Image.svelte";
  import {
    inspectMarkdown,
    inspectRichTextHtml,
    normalizeLinkUrl,
    normalizeImageUrl,
    preserveMarkdownEnvelope,
    roundTripMatches,
    sanitizeRichTextHtml,
    htmlRoundTripMatches,
  } from "./rich-text.js";

  let {
    value = $bindable(""),
    onValueChange,
    format = "html",
    placeholder = "Start writing…",
    disabled = false,
    readonly = false,
    required = false,
    maxlength,
    upload,
    toolbar: customToolbar,
    class: className,
    style,
    id,
    name,
    form,
    onblur,
    onfocus,
    onModeChange,
    ...attributes
  } = $props();
  const generatedId = $props.id();
  const fieldId = $derived(
    id ?? `klean-rich-text-${generatedId.replace(/[^\w-]/g, "")}`,
  );
  let root, mount, source, popover;
  let toolbar = $state.raw(null);
  let linkInput = $state.raw(null);
  let imageInput = $state.raw(null);
  let imageFile = $state.raw(null);
  let editor = $state.raw(null);
  let ready = $state(false);
  let mode = $state("visual");
  let sourceValue = $state(String(value ?? ""));
  const initialValue = String(value ?? "");
  let warning = $state("");
  let status = $state("");
  let validationError = $state("");
  let popup = $state("");
  let popupAnchor = $state.raw(null);
  let linkUrl = $state("");
  let imageUrl = $state("");
  let imageAlt = $state("");
  let popupError = $state("");
  let uploading = $state(false);
  let selectedImage = $state(false);
  let labelText = $state("");
  let toolbarIndex = $state(0);
  let revision = $state(0);
  let syncing = false,
    composing = false,
    destroyed = false,
    pendingExternal,
    selectionBookmark;
  const pendingUploads = new Set();
  let lastRenderedValue;
  const locked = $derived(disabled || readonly);
  const description = $derived(
    [
      attributes["aria-describedby"],
      warning && `${fieldId}-warning`,
      validationError && `${fieldId}-error`,
    ]
      .filter(Boolean)
      .join(" ") || undefined,
  );
  const accessibleLabel = $derived(
    attributes["aria-labelledby"]
      ? undefined
      : (attributes["aria-label"] ?? (labelText || "Rich text")),
  );

  const toolClass =
    "inline-flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-md text-sm text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 aria-pressed:bg-gray-100 aria-pressed:text-gray-950 disabled:cursor-not-allowed disabled:opacity-35 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-white dark:aria-pressed:bg-gray-800 dark:aria-pressed:text-white dark:focus-visible:outline-white motion-reduce:transition-none";
  const fieldClass =
    "min-h-10 w-full min-w-0 rounded-md border border-gray-300 bg-transparent px-3 py-2 text-sm outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 dark:border-gray-700 dark:focus-visible:outline-white";
  const actionClass =
    "min-h-10 cursor-pointer rounded-md bg-gray-950 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-white dark:text-gray-950 dark:hover:bg-gray-200";
  const contentClass =
    "min-h-56 w-full min-w-0 px-5 py-5 text-base/7 outline-none wrap-anywhere *:first:mt-0 *:last:mb-0 [&_p]:my-3 [&_h1]:mt-7 [&_h1]:mb-3 [&_h1]:text-3xl [&_h1]:font-semibold [&_h1]:tracking-tight [&_h2]:mt-6 [&_h2]:mb-3 [&_h2]:text-2xl [&_h2]:font-semibold [&_h2]:tracking-tight [&_h3]:mt-5 [&_h3]:mb-2 [&_h3]:text-xl [&_h3]:font-semibold [&_h4]:mt-4 [&_h4]:font-semibold [&_h5]:font-semibold [&_h6]:font-semibold [&_strong]:font-semibold [&_a]:text-blue-700 [&_a]:underline [&_a]:underline-offset-3 [&_ul]:my-3 [&_ul]:list-disc [&_ul]:ps-6 [&_ol]:my-3 [&_ol]:list-decimal [&_ol]:ps-6 [&_li]:my-1 [&_li>p]:my-1 [&_blockquote]:my-4 [&_blockquote]:border-s-2 [&_blockquote]:border-gray-300 [&_blockquote]:ps-4 [&_blockquote]:text-gray-600 [&_pre]:my-4 [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:bg-gray-100 [&_pre]:p-4 [&_pre]:text-sm [&_code]:rounded [&_code]:bg-gray-100 [&_code]:px-1 [&_code]:font-mono [&_code]:text-[0.9em] [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_img]:my-4 [&_img]:max-h-96 [&_img]:max-w-full [&_img]:rounded-lg [&_img]:object-contain [&_hr]:my-6 [&_hr]:border-gray-200 [&_.ProseMirror-selectednode]:outline-2 [&_.ProseMirror-selectednode]:outline-blue-500 [&_p.is-editor-empty:first-child]:before:pointer-events-none [&_p.is-editor-empty:first-child]:before:float-start [&_p.is-editor-empty:first-child]:before:h-0 [&_p.is-editor-empty:first-child]:before:text-gray-500 [&_p.is-editor-empty:first-child]:before:content-[attr(data-placeholder)] dark:[&_a]:text-blue-400 dark:[&_blockquote]:border-gray-600 dark:[&_blockquote]:text-gray-400 dark:[&_pre]:bg-gray-900 dark:[&_code]:bg-gray-900 dark:[&_hr]:border-gray-800";
  const commands = [
    {
      name: "Heading",
      text: "H₂",
      command: "toggleHeading",
      attributes: { level: 2 },
      active: "heading",
    },
    {
      name: "Bold",
      text: "B",
      command: "toggleBold",
      active: "bold",
      class: "font-bold",
    },
    {
      name: "Italic",
      text: "I",
      command: "toggleItalic",
      active: "italic",
      class: "font-serif italic",
    },
    {
      name: "Strikethrough",
      text: "S",
      command: "toggleStrike",
      active: "strike",
      class: "line-through",
    },
    {
      name: "Bullet list",
      path: "M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01",
      command: "toggleBulletList",
      active: "bulletList",
    },
    {
      name: "Numbered list",
      path: "M10 6h10M10 12h10M10 18h10M3 5l1-1v5M3 9h2M3 15a1.5 1.5 0 0 1 3 0c0 1-3 2-3 4h3",
      command: "toggleOrderedList",
      active: "orderedList",
    },
    {
      name: "Quote",
      path: "M10 6H4v6h5c0 3-2 5-5 6M20 6h-6v6h5c0 3-2 5-5 6",
      command: "toggleBlockquote",
      active: "blockquote",
    },
    {
      name: "Code",
      path: "m8 7-5 5 5 5m8-10 5 5-5 5m-3-14-2 18",
      command: "toggleCodeBlock",
      active: "codeBlock",
      class: "font-mono text-xs",
    },
  ];
  const activeTools = $derived.by(() => {
    revision;
    return commands.map(
      (tool) => editor?.isActive(tool.active, tool.attributes) ?? false,
    );
  });
  const canUndo = $derived.by(() => {
    revision;
    return editor?.can().undo() ?? false;
  });
  const canRedo = $derived.by(() => {
    revision;
    return editor?.can().redo() ?? false;
  });
  const linkActive = $derived.by(() => {
    revision;
    return editor?.isActive("link") ?? false;
  });
  const toolbarContext = $derived.by(() => {
    revision;
    return { editor, mode, setMode, openLink, openImage };
  });

  function emitValue(next) {
    sourceValue = next;
    value = next;
    if (source) source.value = next;
    onValueChange?.(next);
  }
  function commitEditor() {
    if (
      !editor ||
      syncing ||
      locked ||
      mode !== "visual" ||
      composing ||
      pendingExternal !== undefined
    )
      return;
    const next = editor.isEmpty
      ? ""
      : format === "markdown"
        ? preserveMarkdownEnvelope(editor.getMarkdown(), sourceValue)
        : editor.getHTML();
    if (next === sourceValue) return;
    lastRenderedValue = next;
    emitValue(next);
    tick().then(syncValidity);
  }
  function loadValue(next, current = editor) {
    if (!current) return false;
    if (next === lastRenderedValue) {
      warning = "";
      return true;
    }
    const inspection =
      format === "markdown" ? inspectMarkdown(next) : inspectRichTextHtml(next);
    if (!inspection.supported) {
      warning = `Keep editing the source to preserve ${inspection.issues.map((issue) => issue.label).join(", ")}.`;
      changeMode("source");
      return false;
    }
    syncing = true;
    lastRenderedValue = undefined;
    try {
      current.commands.setContent(
        format === "html" ? sanitizeRichTextHtml(next) : next,
        { contentType: format, emitUpdate: false },
      );
      if (format === "html" && !htmlRoundTripMatches(next, current.getHTML())) {
        warning =
          "This content needs source editing so none of its formatting is lost.";
        changeMode("source");
        return false;
      }
      if (
        format === "markdown" &&
        !roundTripMatches(
          next,
          preserveMarkdownEnvelope(current.getMarkdown(), next),
          (content) => current.markdown.parse(content),
        )
      ) {
        warning =
          "This content needs source editing so none of its formatting is lost.";
        changeMode("source");
        return false;
      }
      const historyKey = history().spec.key;
      const historyPlugin = historyKey.get(current.state);
      if (historyPlugin) {
        current.unregisterPlugin(historyKey);
        current.registerPlugin(historyPlugin);
      }
      lastRenderedValue = next;
      warning = "";
      return true;
    } catch {
      warning =
        "This content could not be opened safely. Your source is still intact.";
      changeMode("source");
      return false;
    } finally {
      syncing = false;
    }
  }
  function changeMode(next) {
    if (mode !== next) {
      mode = next;
      onModeChange?.(next);
    }
  }
  export async function setMode(next) {
    if (disabled || !["visual", "source"].includes(next) || next === mode)
      return;
    abortUploads();
    popup = "";
    if (next === "visual" && !loadValue(sourceValue)) return;
    changeMode(next);
    syncEditorAttributes();
    await tick();
    focus();
    syncValidity();
  }
  function updateSource(event) {
    if (locked) return;
    sourceValue = event.target.value;
    if (event.isComposing || composing) return;
    abortUploads();
    emitValue(event.target.value);
    warning = "";
    syncValidity();
  }
  function flushExternal() {
    if (pendingExternal !== undefined) {
      const next = pendingExternal;
      pendingExternal = undefined;
      replaceValue(next);
    }
  }
  function replaceValue(next) {
    if (next === sourceValue) return;
    if (composing || editor?.view.composing) {
      pendingExternal = next;
      return;
    }
    abortUploads();
    popup = "";
    sourceValue = next;
    if (mode === "visual") loadValue(next);
    if (mode !== "source") warning = "";
    tick().then(syncValidity);
  }
  function syncEditorAttributes() {
    if (!editor || destroyed) return;
    editor.setEditable(!locked && mode === "visual", false);
    const aria = Object.fromEntries(
      Object.entries(attributes)
        .filter(
          ([key, value]) =>
            (key.startsWith("aria-") ||
              ["lang", "dir", "title"].includes(key)) &&
            value != null,
        )
        .map(([key, value]) => [key, String(value)]),
    );
    editor.setOptions({
      editorProps: {
        attributes: () =>
          Object.fromEntries(
            Object.entries({
              ...aria,
              role: "textbox",
              "aria-multiline": "true",
              "data-slot": "rich-text-content",
              class: contentClass,
              id: `${fieldId}-editor`,
              "aria-label": accessibleLabel,
              "aria-labelledby": attributes["aria-labelledby"],
              "aria-describedby": description,
              "aria-required": required ? "true" : undefined,
              "aria-invalid": validationError
                ? "true"
                : attributes["aria-invalid"],
              "aria-readonly": readonly ? "true" : undefined,
              "aria-disabled": disabled ? "true" : undefined,
              tabindex: disabled ? "-1" : "0",
              spellcheck: attributes.spellcheck ?? "true",
              dir: attributes.dir,
            }).filter(([, value]) => value != null),
          ),
      },
    });
  }
  function isEmpty() {
    if (!sourceValue.trim()) return true;
    if (mode === "visual" && editor) return editor.isEmpty;
    if (format !== "html" || !source) return false;
    const template = source.ownerDocument.createElement("template");
    template.innerHTML = sanitizeRichTextHtml(sourceValue);
    return (
      !template.content.textContent.replace(/[\s\u200B-\u200D\uFEFF]/g, "") &&
      !template.content.querySelector("img[src],hr")
    );
  }
  function validityMessage() {
    if (locked) return "";
    if (required && isEmpty()) return "Please fill out this field.";
    const max = Number(maxlength);
    if (
      maxlength !== undefined &&
      Number.isFinite(max) &&
      sourceValue.length > max
    )
      return `Use ${max} characters or fewer, including formatting.`;
    return "";
  }
  function syncValidity() {
    if (destroyed) return;
    const message = validityMessage();
    source?.setCustomValidity(message);
    if (validationError) validationError = message;
    syncEditorAttributes();
  }
  function invalid(event) {
    event.preventDefault();
    validationError =
      validityMessage() || source?.validationMessage || "Check this field.";
    syncEditorAttributes();
    focus();
  }
  export function focus(options) {
    if (disabled) return;
    if (ready && mode === "visual") editor?.view.dom.focus(options);
    else source?.focus(options);
  }
  export function getEditor() {
    return editor;
  }
  export function getMode() {
    return mode;
  }
  export function checkValidity() {
    syncValidity();
    return source?.checkValidity() ?? true;
  }
  export function reportValidity() {
    syncValidity();
    return source?.reportValidity() ?? true;
  }
  function tools() {
    return [...(toolbar?.querySelectorAll("button:not(:disabled)") ?? [])];
  }
  function focusToolbar() {
    syncToolbar();
    toolbar?.querySelector('button[tabindex="0"]')?.focus();
  }
  function syncToolbar() {
    const buttons = [...(toolbar?.querySelectorAll("button") ?? [])];
    if (!buttons[toolbarIndex] || buttons[toolbarIndex].disabled)
      toolbarIndex = buttons.findIndex((button) => !button.disabled);
  }
  function toolbarKeydown(event) {
    const buttons = tools(),
      index = buttons.indexOf(event.target);
    if (index < 0) return;
    if (event.key === "Escape") {
      event.preventDefault();
      focus();
      return;
    }
    const rtl = getComputedStyle(toolbar).direction === "rtl";
    let target;
    if (event.key === "Home") target = 0;
    else if (event.key === "End") target = buttons.length - 1;
    else if (event.key === "ArrowRight")
      target = (index + (rtl ? -1 : 1) + buttons.length) % buttons.length;
    else if (event.key === "ArrowLeft")
      target = (index + (rtl ? 1 : -1) + buttons.length) % buttons.length;
    else return;
    event.preventDefault();
    toolbarIndex = [...toolbar.querySelectorAll("button")].indexOf(
      buttons[target],
    );
    buttons[target]?.focus();
  }
  function run(command, args) {
    if (!locked && mode === "visual")
      editor?.chain().focus()[command](args).run();
  }
  function rememberSelection() {
    if (editor) {
      selectionBookmark = editor.state.selection.getBookmark();
      selectedImage = editor.isActive("image");
    }
  }
  function restoreSelection() {
    if (!selectionBookmark || !editor) return;
    try {
      editor.view.dispatch(
        editor.state.tr.setSelection(
          selectionBookmark.resolve(editor.state.doc),
        ),
      );
    } catch {
      /* The selected content may have been removed. */
    }
  }
  async function openLink(event) {
    if (!editor || locked || mode !== "visual") return;
    rememberSelection();
    popupAnchor =
      event?.currentTarget ??
      root?.querySelector('[data-action="link"]') ??
      editor.view.dom;
    linkUrl = editor.getAttributes("link").href ?? "";
    popupError = "";
    popup = "link";
    await tick();
    linkInput?.focus();
  }
  async function openImage(event) {
    if (!editor || locked || mode !== "visual") return;
    rememberSelection();
    popupAnchor =
      event?.currentTarget ??
      root?.querySelector('[data-action="image"]') ??
      editor.view.dom;
    imageUrl = selectedImage ? editor.getAttributes("image").src : "";
    imageAlt = selectedImage ? (editor.getAttributes("image").alt ?? "") : "";
    popupError = "";
    popup = "image";
    await tick();
    imageInput?.focus();
  }
  function closePopup(returnFocus = true) {
    popup = "";
    if (returnFocus) {
      restoreSelection();
      focus();
    }
  }
  function applyLink() {
    if (locked) return;
    const href = normalizeLinkUrl(linkUrl);
    if (!href) {
      popupError = "Enter a safe web, email, phone or relative link.";
      return;
    }
    restoreSelection();
    const chain = editor.chain().focus().extendMarkRange("link");
    if (editor.state.selection.empty && !editor.isActive("link"))
      chain
        .insertContent({
          type: "text",
          text: linkUrl.trim(),
          marks: [{ type: "link", attrs: { href } }],
        })
        .run();
    else chain.setLink({ href }).run();
    closePopup();
  }
  function removeLink() {
    if (locked) return;
    restoreSelection();
    editor?.chain().focus().extendMarkRange("link").unsetLink().run();
    closePopup();
  }
  function applyImage() {
    if (locked) return;
    const src = normalizeImageUrl(imageUrl);
    if (!src) {
      popupError = "Enter a safe image URL.";
      return;
    }
    restoreSelection();
    editor?.chain().focus().setImage({ src, alt: imageAlt.trim() }).run();
    closePopup();
  }
  function abortUploads() {
    if (pendingUploads.size) status = "";
    for (const job of pendingUploads) job.controller.abort();
    pendingUploads.clear();
    uploading = false;
  }
  async function uploadFiles(files, position = editor?.state.selection.from) {
    if (locked || mode !== "visual" || !editor) return;
    if (!upload) {
      status = "Choose an image URL. File uploads are not configured.";
      return;
    }
    const current = editor,
      controller = new AbortController(),
      job = { controller, position };
    pendingUploads.add(job);
    uploading = true;
    status = "Uploading image…";
    closePopup(false);
    try {
      for (const file of Array.from(files)) {
        if (
          ![
            "image/png",
            "image/jpeg",
            "image/gif",
            "image/webp",
            "image/avif",
          ].includes(file.type)
        )
          throw new Error("Choose a PNG, JPEG, GIF, WebP or AVIF image.");
        const result = await upload(file, { signal: controller.signal });
        if (
          destroyed ||
          controller.signal.aborted ||
          locked ||
          mode !== "visual" ||
          editor !== current
        )
          return;
        const src = normalizeImageUrl(result?.src);
        if (!src)
          throw new Error("The upload did not return a safe image URL.");
        current.commands.insertContentAt(
          Math.min(job.position, current.state.doc.content.size),
          {
            type: "image",
            attrs: { src, alt: result.alt ?? "", title: result.title ?? null },
          },
          { updateSelection: false },
        );
      }
      status = "Image added. Select it to edit its description.";
    } catch (error) {
      if (!destroyed && !controller.signal.aborted)
        status = error.message || "The image could not be uploaded. Try again.";
    } finally {
      pendingUploads.delete(job);
      if (!destroyed) uploading = pendingUploads.size > 0;
    }
  }
  function compositeBlur(event) {
    if (root?.contains(event.relatedTarget)) return;
    queueMicrotask(() => {
      if (!destroyed && !root?.contains(root?.getRootNode().activeElement))
        onblur?.(event);
    });
  }
  function popupKeydown(event) {
    if (event.key === "Escape" && !event.defaultPrevented) {
      event.preventDefault();
      closePopup();
    } else if (
      event.key === "Enter" &&
      !event.isComposing &&
      event.target.tagName === "INPUT" &&
      event.target.type !== "file"
    ) {
      event.preventDefault();
      if (popup === "link") applyLink();
      else applyImage();
    }
  }

  onMount(() => {
    destroyed = false;
    labelText = [...(source?.labels ?? [])]
      .map((label) => label.textContent.trim())
      .join(" ");
    const current = new Editor({
      element: mount,
      content: "",
      editable: !locked,
      extensions: [
        StarterKit.configure({
          underline: false,
          link: {
            openOnClick: false,
            autolink: true,
            defaultProtocol: "https",
            isAllowedUri: (url, context) =>
              context.defaultValidate(url) && Boolean(normalizeLinkUrl(url)),
            HTMLAttributes: { rel: "noopener noreferrer", target: null },
          },
        }),
        ImageExtension.configure({ allowBase64: false }),
        Placeholder.configure({ placeholder: () => placeholder }),
        Markdown,
        FileHandler.configure({
          allowedMimeTypes: [
            "image/png",
            "image/jpeg",
            "image/gif",
            "image/webp",
            "image/avif",
          ],
          onPaste: (current, files) =>
            uploadFiles(files, current.state.selection.from),
          onDrop: (_current, files, position) => uploadFiles(files, position),
        }),
      ],
      editorProps: {
        attributes: {
          role: "textbox",
          "aria-multiline": "true",
          "data-slot": "rich-text-content",
          class: contentClass,
        },
        transformPastedHTML: (html) => sanitizeRichTextHtml(html),
        handleKeyDown: (_view, event) => {
          if (event.isComposing) return false;
          if (event.altKey && event.key === "F10") {
            event.preventDefault();
            focusToolbar();
            return true;
          }
          if (
            (event.ctrlKey || event.metaKey) &&
            event.key.toLowerCase() === "k" &&
            !locked
          ) {
            event.preventDefault();
            openLink();
            return true;
          }
          return false;
        },
        handleDOMEvents: {
          focus: (_view, event) => {
            onfocus?.(event);
            return false;
          },
          compositionstart: () => {
            composing = true;
            return false;
          },
          compositionend: () => {
            composing = false;
            queueMicrotask(() => {
              if (!destroyed) {
                if (pendingExternal !== undefined) flushExternal();
                else commitEditor();
              }
            });
            return false;
          },
        },
      },
      onUpdate: commitEditor,
      onTransaction: ({ transaction }) => {
        if (transaction.docChanged) {
          for (const job of pendingUploads)
            job.position = transaction.mapping.map(job.position, 1);
          if (selectionBookmark)
            selectionBookmark = selectionBookmark.map(transaction.mapping);
        }
        revision += 1;
      },
    });
    editor = current;
    loadValue(sourceValue, current);
    ready = true;
    syncEditorAttributes();
    tick().then(syncValidity);
    return () => {
      destroyed = true;
      abortUploads();
      current.destroy();
      editor = null;
    };
  });
  $effect(() => {
    const next = String(value ?? "");
    if (ready) untrack(() => replaceValue(next));
  });
  let previousFormat;
  $effect(() => {
    if (previousFormat === undefined) {
      previousFormat = format;
      return;
    }
    if (format !== previousFormat) {
      previousFormat = format;
      abortUploads();
      lastRenderedValue = undefined;
      loadValue(sourceValue);
      tick().then(syncValidity);
    }
  });
  $effect(() => {
    revision;
    locked;
    mode;
    ready;
    tick().then(() => {
      if (!destroyed) untrack(syncToolbar);
    });
  });
  $effect(() => {
    sourceValue;
    mode;
    locked;
    required;
    maxlength;
    placeholder;
    fieldId;
    description;
    accessibleLabel;
    attributes["aria-invalid"];
    attributes.dir;
    if (!ready) return;
    if (locked) {
      abortUploads();
      popup = "";
    }
    syncEditorAttributes();
    syncValidity();
  });
  $effect(() => {
    form;
    if (!ready) return;
    const owner = source?.form;
    if (!owner) return;
    function reset(event) {
      queueMicrotask(() => {
        if (event.defaultPrevented || destroyed) return;
        abortUploads();
        sourceValue = initialValue;
        warning = status = validationError = "";
        popup = "";
        if (loadValue(initialValue)) changeMode("visual");
        emitValue(initialValue);
        tick().then(syncValidity);
      });
    }
    owner.addEventListener("reset", reset);
    return () => owner.removeEventListener("reset", reset);
  });
</script>

<div
  bind:this={root}
  data-slot="rich-text"
  data-mode={mode}
  data-disabled={disabled || undefined}
  data-readonly={readonly || undefined}
  {style}
  class={twMerge(
    "relative w-full min-w-0 rounded-xl border border-gray-200 bg-white text-gray-950 shadow-sm focus-within:border-gray-400 focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-gray-950/15 data-disabled:opacity-50 dark:border-gray-800 dark:bg-gray-950 dark:text-gray-100 dark:focus-within:border-gray-600 dark:focus-within:outline-white/20",
    className,
  )}
  onfocusout={compositeBlur}
>
  {#if customToolbar}
    {@render customToolbar(toolbarContext)}
  {:else}
    <div
      data-slot="rich-text-toolbar"
      class="flex flex-wrap items-center gap-2 rounded-t-[inherit] bg-gray-50/70 p-2 dark:bg-gray-900/50"
    >
      <div
        bind:this={toolbar}
        role="toolbar"
        tabindex="-1"
        aria-label="Text formatting"
        class="flex min-w-0 flex-wrap items-center gap-0.5"
        onkeydown={toolbarKeydown}
      >
        {#each commands as tool, index (tool.name)}
          <button
            type="button"
            data-slot="rich-text-tool"
            class={twMerge(toolClass, tool.class)}
            aria-label={tool.name}
            title={tool.name}
            aria-pressed={activeTools[index]}
            disabled={!ready || locked || mode === "source"}
            tabindex={toolbarIndex === index ? 0 : -1}
            onfocus={() => (toolbarIndex = index)}
            onmousedown={(event) => event.preventDefault()}
            onclick={() => run(tool.command, tool.attributes)}
            >{#if tool.path}<svg
                class="size-4"
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                stroke-width="1.5"
                stroke-linecap="round"
                stroke-linejoin="round"
                aria-hidden="true"
                focusable="false"><path d={tool.path} /></svg
              >{:else}<span>{tool.text}</span>{/if}</button
          >
        {/each}
        <button
          type="button"
          data-action="link"
          data-slot="rich-text-tool"
          class={toolClass}
          aria-label="Link"
          title="Link · Ctrl/⌘ K"
          aria-pressed={linkActive}
          disabled={!ready || locked || mode === "source"}
          tabindex={toolbarIndex === 8 ? 0 : -1}
          onfocus={() => (toolbarIndex = 8)}
          onmousedown={(event) => event.preventDefault()}
          onclick={openLink}><Link class="size-4" /></button
        >
        <button
          type="button"
          data-action="image"
          data-slot="rich-text-tool"
          class={toolClass}
          aria-label="Image"
          title="Image"
          disabled={!ready || locked || mode === "source"}
          tabindex={toolbarIndex === 9 ? 0 : -1}
          onfocus={() => (toolbarIndex = 9)}
          onmousedown={(event) => event.preventDefault()}
          onclick={openImage}><Image class="size-4" /></button
        >
        <button
          type="button"
          data-slot="rich-text-tool"
          class={toolClass}
          aria-label="Undo"
          title="Undo"
          disabled={!ready || locked || mode === "source" || !canUndo}
          tabindex={toolbarIndex === 10 ? 0 : -1}
          onfocus={() => (toolbarIndex = 10)}
          onmousedown={(event) => event.preventDefault()}
          onclick={() => run("undo")}
          ><svg
            class="size-4"
            viewBox="0 0 24 24"
            fill="none"
            stroke="currentColor"
            stroke-width="1.5"
            stroke-linecap="round"
            stroke-linejoin="round"
            aria-hidden="true"
            focusable="false"
            ><path d="m8 4-5 5 5 5M3 9h11a6 6 0 0 1 0 12h-3" /></svg
          ></button
        >
        <button
          type="button"
          data-slot="rich-text-tool"
          class={toolClass}
          aria-label="Redo"
          title="Redo"
          disabled={!ready || locked || mode === "source" || !canRedo}
          tabindex={toolbarIndex === 11 ? 0 : -1}
          onfocus={() => (toolbarIndex = 11)}
          onmousedown={(event) => event.preventDefault()}
          onclick={() => run("redo")}
          ><svg
            class="size-4"
            viewBox="0 0 24 24"
            fill="none"
            stroke="currentColor"
            stroke-width="1.5"
            stroke-linecap="round"
            stroke-linejoin="round"
            aria-hidden="true"
            focusable="false"
            ><path d="m16 4 5 5-5 5m5-5H10a6 6 0 0 0 0 12h3" /></svg
          ></button
        >
      </div>
      <div
        class="ms-auto flex shrink-0 items-center gap-0.5 rounded-md bg-gray-200/50 p-0.5 dark:bg-gray-800/70"
        role="group"
        aria-label="Editing mode"
      >
        {#each [{ value: "visual", label: "Write" }, { value: "source", label: "Source" }] as item (item.value)}
          <button
            type="button"
            disabled={disabled || !ready}
            aria-pressed={mode === item.value}
            data-slot="rich-text-mode"
            class="min-h-8 cursor-pointer rounded px-2.5 text-xs font-medium text-gray-600 hover:text-gray-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 aria-pressed:bg-white aria-pressed:text-gray-950 aria-pressed:shadow-sm disabled:cursor-not-allowed dark:text-gray-400 dark:hover:text-white dark:aria-pressed:bg-gray-700 dark:aria-pressed:text-white dark:focus-visible:outline-white"
            onclick={() => setMode(item.value)}>{item.label}</button
          >
        {/each}
      </div>
    </div>
  {/if}
  {#if warning}<p
      id={`${fieldId}-warning`}
      data-slot="rich-text-warning"
      class="mx-5 mt-4 rounded-md bg-amber-50 p-3 text-sm/6 text-amber-900 dark:bg-amber-950/40 dark:text-amber-200"
      role="status"
    >
      {warning}
    </p>{/if}
  <div bind:this={mount} hidden={!ready || mode !== "visual"}></div>
  <textarea
    {...attributes}
    bind:this={source}
    id={fieldId}
    {name}
    {form}
    value={sourceValue}
    {disabled}
    {readonly}
    {required}
    aria-label={accessibleLabel}
    aria-describedby={description}
    aria-invalid={validationError ? "true" : attributes["aria-invalid"]}
    aria-hidden={ready && mode === "visual" ? "true" : undefined}
    tabindex={ready && mode === "visual" ? -1 : undefined}
    {placeholder}
    spellcheck={mode === "source" ? false : attributes.spellcheck}
    data-slot="rich-text-source"
    class={ready && mode === "visual"
      ? "sr-only"
      : "block min-h-56 w-full min-w-0 resize-y rounded-b-[inherit] bg-transparent p-5 font-mono text-sm/7 outline-none wrap-anywhere"}
    onfocus={(event) => {
      if (mode === "visual" && ready) focus();
      else onfocus?.(event);
    }}
    oninput={updateSource}
    oncompositionstart={() => (composing = true)}
    oncompositionend={(event) => {
      composing = false;
      if (pendingExternal !== undefined) flushExternal();
      else updateSource(event);
    }}
    oninvalid={invalid}></textarea>
  {#if validationError}<p
      id={`${fieldId}-error`}
      data-slot="rich-text-error"
      class="px-5 pb-3 text-sm text-red-600 dark:text-red-400"
      role="alert"
    >
      {validationError}
    </p>{/if}
  <p
    data-slot="rich-text-status"
    aria-live="polite"
    aria-atomic="true"
    class={status
      ? "px-5 pb-3 text-sm text-gray-600 dark:text-gray-400"
      : "sr-only"}
  >
    {status}
  </p>
  <Popover
    bind:this={popover}
    id={`${fieldId}-popover`}
    open={Boolean(popup)}
    onOpenChange={(open) => {
      if (!open) popup = "";
    }}
    anchor={popupAnchor}
    placement="bottom-start"
    data-rich-text-owner={fieldId}
    role="dialog"
    aria-label={popup === "image" ? "Edit image" : "Edit link"}
    class="w-80 max-w-[calc(100vw-1rem)] rounded-xl p-4"
    onfocusout={compositeBlur}
    onkeydown={popupKeydown}
  >
    {#if popup === "link"}
      <div class="grid gap-3">
        <label for={`${fieldId}-link`} class="text-sm font-medium">Link</label>
        <input
          bind:this={linkInput}
          id={`${fieldId}-link`}
          bind:value={linkUrl}
          class={fieldClass}
          inputmode="url"
          autocomplete="off"
          placeholder="https://example.com"
          aria-invalid={Boolean(popupError)}
          aria-describedby={popupError ? `${fieldId}-popup-error` : undefined}
        />
        <div class="flex flex-wrap gap-2">
          <button type="button" class={actionClass} onclick={applyLink}
            >Apply link</button
          >{#if linkActive}<button
              type="button"
              class="min-h-10 cursor-pointer px-2 text-sm text-red-600"
              onclick={removeLink}>Remove</button
            >{/if}<button
            type="button"
            class="ms-auto min-h-10 cursor-pointer px-2 text-sm"
            onclick={() => closePopup()}>Cancel</button
          >
        </div>
      </div>
    {:else if popup === "image"}
      <div class="grid gap-3">
        <label for={`${fieldId}-image-url`} class="text-sm font-medium"
          >Image URL</label
        >
        <input
          bind:this={imageInput}
          id={`${fieldId}-image-url`}
          bind:value={imageUrl}
          class={fieldClass}
          inputmode="url"
          autocomplete="off"
          placeholder="https://example.com/image.png"
          aria-invalid={Boolean(popupError)}
          aria-describedby={popupError ? `${fieldId}-popup-error` : undefined}
        />
        <label for={`${fieldId}-image-alt`} class="text-sm font-medium"
          >Image description</label
        >
        <input
          id={`${fieldId}-image-alt`}
          bind:value={imageAlt}
          class={fieldClass}
          placeholder="What does this image show?"
        />
        <p class="text-xs/5 text-gray-500 dark:text-gray-400">
          Describe meaningful images. Leave empty only for decoration.
        </p>
        <div class="flex flex-wrap gap-2">
          <button type="button" class={actionClass} onclick={applyImage}
            >{selectedImage ? "Update image" : "Add image"}</button
          ><button
            type="button"
            class="ms-auto min-h-10 cursor-pointer px-2 text-sm"
            onclick={() => closePopup()}>Cancel</button
          >
        </div>
        {#if upload}
          <input
            bind:this={imageFile}
            type="file"
            accept="image/png,image/jpeg,image/gif,image/webp,image/avif"
            class="hidden"
            tabindex="-1"
            disabled={locked}
            onchange={(event) => {
              const files = event.target.files;
              if (files?.length) {
                restoreSelection();
                void uploadFiles(files);
              }
              event.target.value = "";
            }}
          />
          <button
            type="button"
            class="min-h-10 cursor-pointer rounded-md border border-gray-200 px-3 text-sm font-medium hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-900"
            disabled={uploading}
            onclick={() => imageFile?.click()}>Choose image file</button
          >
        {/if}
      </div>
    {/if}
    {#if popupError}<p
        id={`${fieldId}-popup-error`}
        class="mt-3 text-sm text-red-600 dark:text-red-400"
        role="alert"
      >
        {popupError}
      </p>{/if}
  </Popover>
</div>

The installation section's manual tab includes every companion file.

  • Textarea — plain multiline text without formatting.
  • Input — titles and other single-line fields.
  • FileUpload — attachments that live outside the document body.
  • Popover — focused non-modal controls alongside an editing surface.
  • Dialog — a separate, focused editing task when an inline form is inappropriate.
  • Toast — report a confirmed save or upload result.

All open source projects are released under the MIT License.