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.
<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>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>
)
}<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
<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>
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.
npx klean-ui add rich-textThe 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:
View source 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>
<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>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"
/>
</>
)
}<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.
<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.
Links and images
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? }:
// 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 }
}
<RichText v-model="body" :upload="uploadImage" /><RichText value={body} onValueChange={setBody} upload={uploadImage} /><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:
View source 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>
<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>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>
)}
/>
</>
)
}<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:
npx klean-ui add durable-ui<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:
<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
<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(/ /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>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<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.
Related components
- 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.