# Emend Editor without AI

> A Tiptap editor with formatting and save support, without AI.

A shadcn/ui block from the [@emend](https://getemend.vercel.app/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/emend/emend-editor-base).

- Page, with a live preview: https://shadcn.exchange/r/emend/emend-editor-base
- Screenshot: https://shadcn.exchange/shot/emend/emend-editor-base/light.png
- Registry item: https://getemend.vercel.app/r/emend-editor-base.json
- Listed: 2026-10-24, source last changed 2026-10-24

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/emend/emend-editor-base.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `@base-ui/react`, `@hugeicons/core-free-icons`, `@hugeicons/react`, `@tiptap/core`, `@tiptap/extension-find-and-replace`, `@tiptap/extension-image`, `@tiptap/extension-list`, `@tiptap/extension-mention`, `@tiptap/extension-table`, `@tiptap/extension-text-style`, `@tiptap/extensions`, `@tiptap/markdown`, `@tiptap/react`, `@tiptap/starter-kit`, `@tiptap/suggestion`, `clsx`, `tailwind-merge`
- Registry items: `button`, `input`, `input-group`, `popover`, `select`, `separator`, `tooltip`, `@emend/color-picker`

## Files

### `registry/blocks/emend-editor/editor-bubble-menu.tsx`

```tsx
"use client"

import type { Editor } from "@tiptap/core"
import { useEditorState } from "@tiptap/react"
import { BubbleMenu } from "@tiptap/react/menus"
import {
  TextBoldIcon,
  TextItalicIcon,
  TextUnderlineIcon,
} from "@hugeicons/core-free-icons"
import { EditorLinkControl } from "./editor-link-control"
import { controlTrigger, EditorControl } from "./editor-toolbar"

const bubbleMenuOptions = {
  placement: "top" as const,
  offset: 8,
  flip: true,
  shift: true,
}

export function EditorBubbleMenu({ editor }: { readonly editor: Editor }) {
  const active = useEditorState({
    editor,
    selector: ({ editor: currentEditor }) => ({
      bold: currentEditor.isActive("bold"),
      italic: currentEditor.isActive("italic"),
      underline: currentEditor.isActive("underline"),
    }),
  })

  return (
    <BubbleMenu
      editor={editor}
      pluginKey="emendEditorBubbleMenu"
      options={bubbleMenuOptions}
    >
      <div className="flex items-center gap-1 rounded-xl border border-border bg-background p-1 shadow-lg">
        <EditorControl
          label="Bold"
          icon={TextBoldIcon}
          active={active.bold}
          onClick={() => editor.chain().focus().toggleBold().run()}
        />
        <EditorControl
          label="Italic"
          icon={TextItalicIcon}
          active={active.italic}
          onClick={() => editor.chain().focus().toggleItalic().run()}
        />
        <EditorControl
          label="Underline"
          icon={TextUnderlineIcon}
          active={active.underline}
          onClick={() => editor.chain().focus().toggleUnderline().run()}
        />
        <EditorLinkControl
          editor={editor}
          trigger={(linkActive) => controlTrigger(linkActive)}
        />
      </div>
    </BubbleMenu>
  )
}
```

### `registry/blocks/emend-editor/editor-color-control.tsx`

```tsx
"use client"

import type { Editor } from "@tiptap/core"
import { PaintBucketIcon, TextColorIcon } from "@hugeicons/core-free-icons"
import { HugeiconsIcon } from "@hugeicons/react"
import { useRef, useState, type ReactElement } from "react"
import {
  ColorPicker,
  ColorPickerAlpha,
  ColorPickerEyeDropper,
  ColorPickerFormat,
  ColorPickerHue,
  ColorPickerOutput,
  ColorPickerSelection,
} from "@/components/emend/color-picker"
import { Button } from "@/components/ui/button"
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover"
import {
  Tooltip,
  TooltipContent,
  TooltipTrigger,
} from "@/components/ui/tooltip"

const colorControls = {
  text: { label: "Text color", icon: TextColorIcon, reset: "Default color" },
  background: {
    label: "Background color",
    icon: PaintBucketIcon,
    reset: "No background",
  },
} as const

export function EditorColorControl({
  editor,
  type,
  color,
  trigger,
  strokeWidth,
}: {
  readonly editor: Editor
  readonly type: keyof typeof colorControls
  readonly color: string | null
  readonly trigger: (active: boolean) => ReactElement
  readonly strokeWidth?: number
}) {
  const [open, setOpen] = useState(false)
  const historyGroup = useRef({})
  const { label, icon, reset } = colorControls[type]

  // Mark-only steps never count as adjacent in ProseMirror history, so every
  // drag tick would become its own undo event. A shared composition id merges
  // all changes made while this picker is open into one event.
  function applyColor(value: string) {
    const chain = editor.chain().setMeta("composition", historyGroup.current)
    if (type === "text") chain.setColor(value).run()
    else chain.setBackgroundColor(value).run()
  }

  function resetColor() {
    setOpen(false)
    if (type === "text") editor.chain().focus().unsetColor().run()
    else editor.chain().focus().unsetBackgroundColor().run()
  }

  return (
    <Popover
      open={open}
      onOpenChange={(nextOpen) => {
        if (nextOpen) historyGroup.current = {}
        setOpen(nextOpen)
      }}
    >
      <Tooltip>
        <TooltipTrigger
          render={
            <PopoverTrigger
              render={trigger(Boolean(color))}
              aria-label={label}
              onMouseDown={(event) => event.preventDefault()}
            />
          }
        >
          <HugeiconsIcon icon={icon} strokeWidth={strokeWidth} />
          {color && (
            <span
              aria-hidden
              className="absolute inset-x-[22%] bottom-[12%] h-0.5 rounded-full"
              style={{ background: color }}
            />
          )}
        </TooltipTrigger>
        <TooltipContent>{label}</TooltipContent>
      </Tooltip>
      <PopoverContent
        align="start"
        aria-label={label}
        className="w-64"
        finalFocus={(closeType) => {
          if (closeType === "keyboard") editor.commands.focus()
          return false
        }}
      >
        <ColorPicker
          value={color ?? undefined}
          defaultValue={type === "text" ? "#000000" : "#fff3a3"}
          onChange={applyColor}
          className="gap-3"
        >
          <ColorPickerSelection className="h-36" />
          <div className="flex items-center gap-3">
            <ColorPickerEyeDropper />
            <div className="grid w-full gap-2">
              <ColorPickerHue />
              <ColorPickerAlpha />
            </div>
          </div>
          <div className="flex items-center gap-2">
            <ColorPickerOutput />
            <ColorPickerFormat />
          </div>
        </ColorPicker>
        <Button
          type="button"
          variant="outline"
          size="sm"
          disabled={!color}
          onClick={resetColor}
        >
          {reset}
        </Button>
      </PopoverContent>
    </Popover>
  )
}
```

### `registry/blocks/emend-editor/editor-find-replace.tsx`

```tsx
"use client"

import type { Editor } from "@tiptap/core"
import { useEditorState } from "@tiptap/react"
import {
  ArrowDown01Icon,
  ArrowUp01Icon,
  Cancel01Icon,
  Search01Icon,
} from "@hugeicons/core-free-icons"
import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react"
import { useId, useState, type KeyboardEvent, type ReactElement } from "react"
import { Button } from "@/components/ui/button"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
} from "@/components/ui/input-group"
import {
  Tooltip,
  TooltipContent,
  TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"

export function EditorFindReplace({
  editor,
  trigger,
  strokeWidth,
  className,
}: {
  readonly editor: Editor
  readonly trigger: (active: boolean) => ReactElement
  readonly strokeWidth?: number
  readonly className?: string
}) {
  const id = useId()
  const [open, setOpen] = useState(false)
  const [term, setTerm] = useState("")
  const [replacement, setReplacement] = useState("")
  const { count, currentIndex } = useEditorState({
    editor,
    selector: ({ editor: currentEditor }) => ({
      count: currentEditor.storage.findAndReplace.results.length,
      currentIndex: currentEditor.storage.findAndReplace.currentIndex,
    }),
  })
  const panelId = `${id}-panel`
  const position = count === 0 ? 0 : (currentIndex ?? 0) + 1

  function close() {
    setOpen(false)
    setTerm("")
    setReplacement("")
    editor.chain().clearSearch().setReplaceTerm("").focus().run()
  }

  function handleKeyDown(
    event: KeyboardEvent<HTMLInputElement>,
    onEnter: () => void
  ) {
    if (event.key === "Escape") {
      event.preventDefault()
      close()
    }
    if (event.key === "Enter") {
      event.preventDefault()
      onEnter()
    }
  }

  return (
    <>
      <Tooltip>
        <TooltipTrigger
          render={trigger(open)}
          aria-label="Find and replace"
          aria-expanded={open}
          aria-controls={open ? panelId : undefined}
          onMouseDown={(event) => event.preventDefault()}
          onClick={() => (open ? close() : setOpen(true))}
        >
          <HugeiconsIcon icon={Search01Icon} strokeWidth={strokeWidth} />
        </TooltipTrigger>
        <TooltipContent>Find and replace</TooltipContent>
      </Tooltip>

      {open && (
        <div
          id={panelId}
          role="dialog"
          aria-label="Find and replace"
          className={cn(
            "absolute top-[calc(100%+0.375rem)] right-1.5 z-50 flex w-[min(24rem,calc(100%-0.75rem))] flex-col gap-1.5 rounded-lg bg-popover p-2 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10",
            className
          )}
        >
          <div className="flex items-center gap-1">
            <InputGroup className="h-7 flex-1">
              <InputGroupAddon>
                <HugeiconsIcon icon={Search01Icon} />
              </InputGroupAddon>
              <InputGroupInput
                autoFocus
                value={term}
                placeholder="Find"
                aria-label="Find"
                spellCheck={false}
                className="h-7"
                onChange={(event) => {
                  setTerm(event.target.value)
                  editor.commands.setSearchTerm(event.target.value)
                }}
                onKeyDown={(event) =>
                  handleKeyDown(event, () =>
                    event.shiftKey
                      ? editor.commands.goToPreviousResult()
                      : editor.commands.goToNextResult()
                  )
                }
              />
              {term && (
                <InputGroupAddon
                  align="inline-end"
                  className="text-xs font-normal tabular-nums"
                  aria-live="polite"
                >
                  {count === 0 ? "No results" : `${position} of ${count}`}
                </InputGroupAddon>
              )}
            </InputGroup>
            <PanelButton
              label="Previous match"
              icon={ArrowUp01Icon}
              disabled={count === 0}
              onClick={() => editor.commands.goToPreviousResult()}
            />
            <PanelButton
              label="Next match"
              icon={ArrowDown01Icon}
              disabled={count === 0}
              onClick={() => editor.commands.goToNextResult()}
            />
            <PanelButton
              label="Close find and replace"
              icon={Cancel01Icon}
              onClick={close}
            />
          </div>

          <div className="flex items-center gap-1">
            <InputGroup className="h-7 flex-1">
              <InputGroupInput
                value={replacement}
                placeholder="Replace with"
                aria-label="Replace with"
                spellCheck={false}
                className="h-7"
                onChange={(event) => {
                  setReplacement(event.target.value)
                  editor.commands.setReplaceTerm(event.target.value)
                }}
                onKeyDown={(event) =>
                  handleKeyDown(event, () => editor.commands.replace())
                }
              />
            </InputGroup>
            <Button
              type="button"
              variant="outline"
              size="sm"
              disabled={count === 0}
              onClick={() => editor.commands.replace()}
            >
              Replace
            </Button>
            <Button
              type="button"
              variant="outline"
              size="sm"
              disabled={count === 0}
              onClick={() => editor.commands.replaceAll()}
            >
              Replace all
            </Button>
          </div>
        </div>
      )}
    </>
  )
}

function PanelButton({
  label,
  icon,
  disabled,
  onClick,
}: {
  readonly label: string
  readonly icon: IconSvgElement
  readonly disabled?: boolean
  readonly onClick: () => void
}) {
  return (
    <Tooltip>
      <TooltipTrigger
        render={
          <Button
            type="button"
            variant="ghost"
            size="icon-sm"
            aria-label={label}
            disabled={disabled}
            onClick={onClick}
          >
            <HugeiconsIcon icon={icon} />
          </Button>
        }
      />
      <TooltipContent>{label}</TooltipContent>
    </Tooltip>
  )
}
```

### `registry/blocks/emend-editor/editor-image-control.tsx`

```tsx
"use client"

import type { Editor } from "@tiptap/core"
import { Image02Icon, Upload04Icon } from "@hugeicons/core-free-icons"
import { HugeiconsIcon } from "@hugeicons/react"
import {
  useId,
  useRef,
  useState,
  type FormEvent,
  type ReactElement,
} from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover"
import {
  Tooltip,
  TooltipContent,
  TooltipTrigger,
} from "@/components/ui/tooltip"

export type EditorImageUpload = (file: File) => Promise<string>

const imageTypes = [
  "image/png",
  "image/jpeg",
  "image/gif",
  "image/webp",
  "image/avif",
]
const maxEmbeddedImageSize = 2 * 1024 * 1024

function readFileAsDataUrl(file: File) {
  return new Promise<string>((resolve, reject) => {
    const reader = new FileReader()
    reader.onload = () => resolve(String(reader.result))
    reader.onerror = () => reject(reader.error)
    reader.readAsDataURL(file)
  })
}

function loadImage(src: string) {
  return new Promise<void>((resolve, reject) => {
    const image = new window.Image()
    image.onload = () => resolve()
    image.onerror = () => reject(new Error("Image failed to load"))
    image.src = src
  })
}

function normalizeImageUrl(value: string) {
  const text = value.trim()
  if (!text || /\s/.test(text)) return null
  if (text.startsWith("/") && !text.startsWith("//")) return text

  try {
    const url = new URL(text)
    return url.protocol === "https:" || url.protocol === "http:" ? text : null
  } catch {
    return null
  }
}

function validateFile(file: File, embedded: boolean) {
  if (!imageTypes.includes(file.type)) {
    return "Choose a PNG, JPEG, GIF, WebP or AVIF image"
  }
  if (embedded && file.size > maxEmbeddedImageSize) {
    return "Choose an image smaller than 2 MB"
  }
  return null
}

export function EditorImageControl({
  editor,
  trigger,
  strokeWidth,
  onUpload,
}: {
  readonly editor: Editor
  readonly trigger: (active: boolean) => ReactElement
  readonly strokeWidth?: number
  readonly onUpload?: EditorImageUpload
}) {
  const id = useId()
  const fileInput = useRef<HTMLInputElement>(null)
  const range = useRef({ from: 0, to: 0 })
  const doc = useRef(editor.state.doc)
  const request = useRef(0)
  const [open, setOpen] = useState(false)
  const [file, setFile] = useState<File | null>(null)
  const [url, setUrl] = useState("")
  const [alt, setAlt] = useState("")
  const [pending, setPending] = useState(false)
  const [error, setError] = useState<string | null>(null)

  function changeOpen(nextOpen: boolean) {
    request.current += 1
    if (nextOpen) {
      const { from, to } = editor.state.selection
      range.current = { from, to }
      doc.current = editor.state.doc
      setFile(null)
      setUrl("")
      setAlt("")
      setError(null)
    }
    setPending(false)
    setOpen(nextOpen)
  }

  async function resolveSource() {
    if (file) {
      const fileError = validateFile(file, !onUpload)
      if (fileError) throw new Error(fileError)

      const src = onUpload
        ? await onUpload(file)
        : await readFileAsDataUrl(file)
      await loadImage(src).catch(() => {
        throw new Error("This image could not be read")
      })
      return src
    }

    const src = normalizeImageUrl(url)
    if (!src) {
      throw new Error("Enter an image URL, like https://example.com/image.png")
    }
    await loadImage(src).catch(() => {
      throw new Error("No image could be loaded from this URL")
    })
    return src
  }

  async function submit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault()
    if (pending) return
    if (!file && !url.trim()) {
      setError("Choose an image file or enter an image URL")
      return
    }

    const current = ++request.current
    setPending(true)
    setError(null)
    try {
      const src = await resolveSource()
      if (current !== request.current) return
      if (editor.state.doc !== doc.current) {
        throw new Error(
          "The document changed while the image was loading. Close this and choose where to insert it again."
        )
      }

      editor
        .chain()
        .focus()
        .insertContentAt(range.current, {
          type: "image",
          attrs: { src, alt: alt.trim() || null },
        })
        .run()
      changeOpen(false)
    } catch (reason) {
      if (current !== request.current) return
      setPending(false)
      setError(
        reason instanceof Error && reason.message
          ? reason.message
          : "The image could not be added"
      )
    }
  }

  const errorId = `${id}-error`

  return (
    <Popover open={open} onOpenChange={changeOpen}>
      <Tooltip>
        <TooltipTrigger
          render={
            <PopoverTrigger
              render={trigger(false)}
              aria-label="Insert image"
              onMouseDown={(event) => event.preventDefault()}
            />
          }
        >
          <HugeiconsIcon icon={Image02Icon} strokeWidth={strokeWidth} />
        </TooltipTrigger>
        <TooltipContent>Insert image</TooltipContent>
      </Tooltip>
      <PopoverContent
        align="start"
        aria-label="Insert image"
        className="w-80 max-w-[calc(100vw-1rem)]"
        finalFocus={(closeType) => {
          if (closeType === "keyboard") editor.commands.focus()
          return false
        }}
      >
        <form
          className="flex flex-col gap-2"
          aria-busy={pending}
          onSubmit={(event) => void submit(event)}
          noValidate
        >
          <input
            ref={fileInput}
            type="file"
            accept={imageTypes.join(",")}
            className="sr-only"
            tabIndex={-1}
            aria-hidden
            onChange={(event) => {
              const nextFile = event.target.files?.[0] ?? null
              event.target.value = ""
              if (!nextFile) return
              setFile(nextFile)
              setUrl("")
              setError(validateFile(nextFile, !onUpload))
            }}
          />
          <Button
            type="button"
            variant="outline"
            className="min-w-0 justify-start"
            disabled={pending}
            onClick={() => fileInput.current?.click()}
          >
            <HugeiconsIcon icon={Upload04Icon} />
            <span className="truncate">
              {file ? file.name : "Choose image file"}
            </span>
          </Button>
          <p className="text-center text-xs text-muted-foreground">or</p>
          <Input
            type="url"
            inputMode="url"
            aria-label="Image URL"
            placeholder="https://example.com/image.png"
            autoComplete="off"
            spellCheck={false}
            value={url}
            disabled={pending}
            aria-invalid={Boolean(error) && !file}
            aria-describedby={error ? errorId : undefined}
            onChange={(event) => {
              setUrl(event.target.value)
              setFile(null)
              setError(null)
            }}
          />
          <Input
            aria-label="Alt text"
            placeholder="Alt text (optional)"
            value={alt}
            disabled={pending}
            onChange={(event) => setAlt(event.target.value)}
          />
          {error && (
            <p id={errorId} role="alert" className="text-xs text-destructive">
              {error}
            </p>
          )}
          <div className="flex items-center justify-end gap-1">
            <Button
              type="button"
              variant="ghost"
              size="sm"
              onClick={() => {
                changeOpen(false)
                editor.commands.focus()
              }}
            >
              Cancel
            </Button>
            <Button type="submit" size="sm" disabled={pending}>
              {pending
                ? onUpload && file
                  ? "Uploading…"
                  : "Loading…"
                : "Insert image"}
            </Button>
          </div>
        </form>
      </PopoverContent>
    </Popover>
  )
}
```

### `registry/blocks/emend-editor/editor-mention.tsx`

```tsx
"use client"

import { Mention } from "@tiptap/extension-mention"
import { ReactRenderer } from "@tiptap/react"
import type {
  SuggestionKeyDownProps,
  SuggestionProps,
} from "@tiptap/suggestion"
import { forwardRef, useEffect, useImperativeHandle, useState } from "react"
import { cn } from "@/lib/utils"

export interface EditorMentionItem {
  readonly id: string
  readonly label: string
}

export const editorMentionItems: readonly EditorMentionItem[] = [
  { id: "ai-bubble-menu", label: "AI Bubble Menu" },
  { id: "ai-composer", label: "AI Composer" },
  { id: "ai-assistant", label: "AI Assistant" },
  { id: "emend-editor", label: "Emend Editor" },
]

interface MentionListProps {
  readonly command: (item: EditorMentionItem) => void
  readonly items: readonly EditorMentionItem[]
}

interface MentionListRef {
  readonly onKeyDown: (props: SuggestionKeyDownProps) => boolean
}

const MentionList = forwardRef<MentionListRef, MentionListProps>(
  function MentionList({ command, items }, ref) {
    const [active, setActive] = useState(0)

    useEffect(() => setActive(0), [items])

    function select(index: number) {
      const item = items[index]
      if (item) command(item)
    }

    useImperativeHandle(ref, () => ({
      onKeyDown: ({ event }) => {
        if (items.length === 0) return false
        if (event.key === "ArrowDown") {
          setActive((current) => (current + 1) % items.length)
          return true
        }
        if (event.key === "ArrowUp") {
          setActive((current) => (current + items.length - 1) % items.length)
          return true
        }
        if (event.key === "Enter") {
          select(active)
          return true
        }
        return false
      },
    }))

    if (items.length === 0) return null

    return (
      <div
        role="listbox"
        aria-label="Mention"
        className="z-50 w-52 rounded-lg border bg-popover p-1 text-popover-foreground shadow-lg"
      >
        {items.map((item, index) => (
          <button
            key={item.id}
            type="button"
            role="option"
            aria-selected={index === active}
            className={cn(
              "flex w-full items-center rounded-md px-2 py-1.5 text-left text-sm outline-none",
              index === active
                ? "bg-muted text-foreground"
                : "text-muted-foreground"
            )}
            onMouseDown={(event) => event.preventDefault()}
            onMouseEnter={() => setActive(index)}
            onClick={() => select(index)}
          >
            {item.label}
          </button>
        ))}
      </div>
    )
  }
)

export function createEditorMention(items: readonly EditorMentionItem[]) {
  return Mention.configure({
    HTMLAttributes: { class: "emend-editor__mention" },
    suggestion: {
      char: "@",
      items: ({ query }) =>
        items.filter((item) =>
          item.label.toLowerCase().includes(query.toLowerCase())
        ),
      render: () => {
        let component: ReactRenderer<MentionListRef, MentionListProps> | null =
          null
        let clientRect: SuggestionProps<EditorMentionItem>["clientRect"] =
          null

        function position() {
          const element = component?.element
          const rect = clientRect?.()
          if (!(element instanceof HTMLElement) || !rect) return
          const { offsetWidth: width, offsetHeight: height } = element
          const below = rect.bottom + 4
          const top =
            below + height > window.innerHeight && rect.top - 4 - height >= 0
              ? rect.top - 4 - height
              : below
          const left = Math.max(
            4,
            Math.min(rect.left, window.innerWidth - width - 4)
          )
          Object.assign(element.style, {
            position: "fixed",
            top: `${top}px`,
            left: `${left}px`,
            zIndex: "50",
          })
        }

        return {
          onStart: (props: SuggestionProps<EditorMentionItem>) => {
            component = new ReactRenderer(MentionList, {
              props,
              editor: props.editor,
            })
            clientRect = props.clientRect
            document.body.appendChild(component.element)
            window.addEventListener("scroll", position, true)
            window.addEventListener("resize", position)
            position()
            requestAnimationFrame(position)
          },
          onUpdate: (props: SuggestionProps<EditorMentionItem>) => {
            component?.updateProps(props)
            clientRect = props.clientRect
            requestAnimationFrame(position)
          },
          onKeyDown: (props) => component?.ref?.onKeyDown(props) ?? false,
          onExit: () => {
            window.removeEventListener("scroll", position, true)
            window.removeEventListener("resize", position)
            component?.element.remove()
            component?.destroy()
            component = null
            clientRect = null
          },
        }
      },
    },
  })
}
```

### `registry/blocks/emend-editor/editor-link-control.tsx`

```tsx
"use client"

import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
import {
  getMarkRange,
  posToDOMRect,
  type Editor,
  type EditorEvents,
} from "@tiptap/core"
import { useEditorState } from "@tiptap/react"
import {
  Link01Icon,
  LinkSquare02Icon,
  Unlink02Icon,
} from "@hugeicons/core-free-icons"
import { HugeiconsIcon } from "@hugeicons/react"
import {
  useEffect,
  useId,
  useState,
  type FormEvent,
  type ReactElement,
} from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
  Tooltip,
  TooltipContent,
  TooltipTrigger,
} from "@/components/ui/tooltip"

interface LinkDraft {
  readonly from: number
  readonly to: number
  readonly selection: { readonly from: number; readonly to: number }
  readonly existing: boolean
  readonly doc: Editor["state"]["doc"]
}

function normalizeLinkUrl(value: string) {
  const text = value.trim()
  if (!text || /\s/.test(text)) return null
  if (/^(?:[/#?]|\.\.?\/)/.test(text)) return text

  const href = /^(https?:\/\/|mailto:|tel:)/i.test(text)
    ? text
    : `https://${text}`
  try {
    const url = new URL(href)
    if (url.protocol === "mailto:" || url.protocol === "tel:") {
      return url.pathname ? href : null
    }
    return url.hostname.includes(".") || url.hostname === "localhost"
      ? href
      : null
  } catch {
    return null
  }
}

export function EditorLinkControl({
  editor,
  trigger,
  strokeWidth,
}: {
  readonly editor: Editor
  readonly trigger: (active: boolean) => ReactElement
  readonly strokeWidth?: number
}) {
  const id = useId()
  const active = useEditorState({
    editor,
    selector: ({ editor: currentEditor }) => currentEditor.isActive("link"),
  })
  const [draft, setDraft] = useState<LinkDraft | null>(null)
  const [href, setHref] = useState("")
  const [text, setText] = useState("")
  const [error, setError] = useState<string | null>(null)
  const isOpen = Boolean(draft)

  useEffect(() => {
    if (!isOpen) return
    const closeOnDocChange = ({
      transaction,
    }: EditorEvents["transaction"]) => {
      if (transaction.docChanged) setDraft(null)
    }
    editor.on("transaction", closeOnDocChange)
    return () => {
      editor.off("transaction", closeOnDocChange)
    }
  }, [editor, isOpen])

  function isStale() {
    if (draft && editor.state.doc === draft.doc) return false
    setDraft(null)
    return true
  }

  function openDraft() {
    const { selection, schema } = editor.state
    const { from, to, empty, $from } = selection
    const linkType = schema.marks.link
    const linkRange = empty && linkType ? getMarkRange($from, linkType) : null
    const range = linkRange ?? { from, to }

    setDraft({
      ...range,
      selection: { from, to },
      existing: editor.isActive("link"),
      doc: editor.state.doc,
    })
    setHref(editor.getAttributes("link").href ?? "")
    setText("")
    setError(null)
  }

  function close() {
    setDraft(null)
    editor.commands.focus()
  }

  function submit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault()
    if (!draft || isStale()) return

    const url = normalizeLinkUrl(href)
    if (!url) {
      setError("Enter a valid URL, like https://example.com")
      return
    }

    const applied =
      draft.from === draft.to
        ? editor
            .chain()
            .focus()
            .insertContentAt(draft.from, {
              type: "text",
              text: text.trim() || url,
              marks: [{ type: "link", attrs: { href: url } }],
            })
            .run()
        : editor
            .chain()
            .focus()
            .setTextSelection({ from: draft.from, to: draft.to })
            .setLink({ href: url })
            .setTextSelection(draft.selection)
            .run()

    if (!applied) {
      setError("This link is not allowed")
      return
    }
    setDraft(null)
  }

  function removeLink() {
    if (!draft || isStale()) return
    editor
      .chain()
      .focus()
      .setTextSelection({ from: draft.from, to: draft.to })
      .unsetLink()
      .setTextSelection(draft.selection)
      .run()
    setDraft(null)
  }

  const label = active ? "Edit link" : "Add link"
  const errorId = `${id}-error`
  const validHref = draft?.existing ? normalizeLinkUrl(href) : null

  return (
    <PopoverPrimitive.Root
      open={isOpen}
      onOpenChange={(open) => {
        if (open) openDraft()
        else setDraft(null)
      }}
    >
      <Tooltip>
        <TooltipTrigger
          render={
            <PopoverPrimitive.Trigger
              render={trigger(active)}
              aria-label={label}
              onMouseDown={(event) => event.preventDefault()}
            />
          }
        >
          <HugeiconsIcon icon={Link01Icon} strokeWidth={strokeWidth} />
        </TooltipTrigger>
        <TooltipContent>{label}</TooltipContent>
      </Tooltip>
      <PopoverPrimitive.Portal>
        <PopoverPrimitive.Positioner
          anchor={() =>
            draft
              ? {
                  getBoundingClientRect: () => {
                    const size = editor.state.doc.content.size
                    return posToDOMRect(
                      editor.view,
                      Math.min(draft.from, size),
                      Math.min(draft.to, size)
                    )
                  },
                }
              : null
          }
          side="bottom"
          align="start"
          sideOffset={8}
          className="isolate z-50"
        >
          <PopoverPrimitive.Popup
            aria-label={label}
            className="z-50 w-80 max-w-[calc(100vw-1rem)] rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden"
            finalFocus={(closeType) => {
              if (closeType === "keyboard") editor.commands.focus()
              return false
            }}
          >
            <form className="flex flex-col gap-2" onSubmit={submit} noValidate>
              {draft && draft.from === draft.to && (
                <Input
                  aria-label="Link text"
                  placeholder="Link text (optional)"
                  value={text}
                  onChange={(event) => setText(event.target.value)}
                />
              )}
              <Input
                type="url"
                inputMode="url"
                aria-label="Link URL"
                placeholder="https://example.com"
                autoComplete="off"
                spellCheck={false}
                value={href}
                aria-invalid={Boolean(error)}
                aria-describedby={error ? errorId : undefined}
                onChange={(event) => {
                  setHref(event.target.value)
                  setError(null)
                }}
              />
              {error && (
                <p
                  id={errorId}
                  role="alert"
                  className="text-xs text-destructive"
                >
                  {error}
                </p>
              )}
              <div className="flex items-center gap-1">
                {draft?.existing && (
                  <>
                    <Tooltip>
                      <TooltipTrigger
                        render={
                          <Button
                            type="button"
                            variant="ghost"
                            size="icon-sm"
                            aria-label="Remove link"
                            onClick={removeLink}
                          >
                            <HugeiconsIcon icon={Unlink02Icon} />
                          </Button>
                        }
                      />
                      <TooltipContent>Remove link</TooltipContent>
                    </Tooltip>
                    {validHref && (
                      <Tooltip>
                        <TooltipTrigger
                          render={
                            <Button
                              variant="ghost"
                              size="icon-sm"
                              aria-label="Open link in new tab"
                              nativeButton={false}
                              render={
                                <a
                                  href={validHref}
                                  target="_blank"
                                  rel="noopener noreferrer"
                                />
                              }
                            >
                              <HugeiconsIcon icon={LinkSquare02Icon} />
                            </Button>
                          }
                        />
                        <TooltipContent>Open link in new tab</TooltipContent>
                      </Tooltip>
                    )}
                  </>
                )}
                <Button
                  type="button"
                  variant="ghost"
                  size="sm"
                  className="ml-auto"
                  onClick={close}
                >
                  Cancel
                </Button>
                <Button type="submit" size="sm">
                  {draft?.existing ? "Save" : "Add link"}
                </Button>
              </div>
            </form>
          </PopoverPrimitive.Popup>
        </PopoverPrimitive.Positioner>
      </PopoverPrimitive.Portal>
    </PopoverPrimitive.Root>
  )
}
```

### `registry/blocks/emend-editor/editor-toolbar-scroller.tsx`

```tsx
"use client"

import { ArrowLeft01Icon, ArrowRight01Icon } from "@hugeicons/core-free-icons"
import { HugeiconsIcon } from "@hugeicons/react"
import { useEffect, useRef, useState, type ReactNode } from "react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"

export function EditorToolbarScroller({
  children,
  className,
  size = "icon",
}: {
  readonly children: ReactNode
  readonly className?: string
  readonly size?: "icon" | "icon-xs"
}) {
  const viewport = useRef<HTMLDivElement>(null)
  const content = useRef<HTMLDivElement>(null)
  const [overflow, setOverflow] = useState({ start: false, end: false })

  useEffect(() => {
    const element = viewport.current
    if (!element || !content.current) return

    const update = () => {
      const maxScroll = element.scrollWidth - element.clientWidth
      setOverflow({
        start: element.scrollLeft > 1,
        end: element.scrollLeft < maxScroll - 1,
      })
    }
    const observer = new ResizeObserver(update)
    observer.observe(element)
    observer.observe(content.current)
    element.addEventListener("scroll", update, { passive: true })
    update()
    return () => {
      observer.disconnect()
      element.removeEventListener("scroll", update)
    }
  }, [])

  function scroll(direction: 1 | -1) {
    const element = viewport.current
    if (!element) return
    element.scrollBy({
      left: direction * element.clientWidth * 0.75,
      behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches
        ? "auto"
        : "smooth",
    })
  }

  const scrollable = overflow.start || overflow.end

  return (
    <div className={cn("flex min-w-0 flex-1 items-center", className)}>
      {scrollable && (
        <ScrollButton
          direction={-1}
          size={size}
          disabled={!overflow.start}
          onClick={() => scroll(-1)}
        />
      )}
      <div
        ref={viewport}
        className="min-w-0 flex-1 overflow-x-auto overscroll-x-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
      >
        <div ref={content} className="flex w-max items-center">
          {children}
        </div>
      </div>
      {scrollable && (
        <ScrollButton
          direction={1}
          size={size}
          disabled={!overflow.end}
          onClick={() => scroll(1)}
        />
      )}
    </div>
  )
}

function ScrollButton({
  direction,
  size,
  disabled,
  onClick,
}: {
  readonly direction: 1 | -1
  readonly size: "icon" | "icon-xs"
  readonly disabled: boolean
  readonly onClick: () => void
}) {
  return (
    <Button
      type="button"
      variant="ghost"
      size={size}
      className="data-disabled:opacity-40"
      aria-label={
        direction === 1 ? "Scroll toolbar right" : "Scroll toolbar left"
      }
      disabled={disabled}
      focusableWhenDisabled
      onMouseDown={(event) => event.preventDefault()}
      onClick={onClick}
    >
      <HugeiconsIcon
        icon={direction === 1 ? ArrowRight01Icon : ArrowLeft01Icon}
      />
    </Button>
  )
}
```

### `registry/blocks/emend-editor/editor-toolbar.tsx`

```tsx
"use client"

import type { Editor } from "@tiptap/core"
import { useEditorState } from "@tiptap/react"
import {
  ALargeSmallIcon,
  AiEditingIcon,
  AiChat02Icon,
  AlignHorizontalCenterIcon,
  AlignLeftIcon,
  AlignRightIcon,
  AtIcon,
  CheckListIcon,
  CodeSimpleIcon,
  Delete02Icon,
  DeleteColumnIcon,
  DeleteRowIcon,
  GridTableIcon,
  HeadingIcon,
  HighlighterIcon,
  InsertColumnRightIcon,
  InsertRowDownIcon,
  LeftToRightBlockQuoteIcon,
  LeftToRightListBulletIcon,
  LeftToRightListNumberIcon,
  RedoIcon,
  SeparatorHorizontalIcon,
  SourceCodeIcon,
  TableRowsSplitIcon,
  TextBoldIcon,
  TextClearIcon,
  TextItalicIcon,
  TextStrikethroughIcon,
  TextUnderlineIcon,
  UndoIcon,
} from "@hugeicons/core-free-icons"
import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react"
import type { ReactNode } from "react"
import { Button } from "@/components/ui/button"
import {
  Select,
  SelectContent,
  SelectGroup,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"
import { Separator } from "@/components/ui/separator"
import {
  Tooltip,
  TooltipContent,
  TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import { EditorColorControl } from "./editor-color-control"
import { EditorFindReplace } from "./editor-find-replace"
import {
  EditorImageControl,
  type EditorImageUpload,
} from "./editor-image-control"
import { EditorLinkControl } from "./editor-link-control"
import { EditorToolbarScroller } from "./editor-toolbar-scroller"

const headingLevels = [1, 2, 3, 4, 5, 6] as const
const fontSizeOptions = ["12", "14", "16", "18", "24", "32"] as const
const highlightColor = "color-mix(in oklch, var(--chart-1) 45%, transparent)"

export type EditorToolbarVariant = "compact" | "classic"

type ControlSize = "icon" | "icon-xs"

export function EditorToolbar({
  editor,
  variant = "compact",
  onSave,
  onAssistantToggle,
  onComposerToggle,
  onImageUpload,
  saveDisabled,
  saveStatus,
  assistantOpen,
  composerOpen,
  toolbarActions,
}: {
  readonly editor: Editor
  readonly variant?: EditorToolbarVariant
  readonly onSave?: () => Promise<void>
  readonly onAssistantToggle?: () => void
  readonly onComposerToggle?: () => void
  readonly onImageUpload?: EditorImageUpload
  readonly saveDisabled: boolean
  readonly saveStatus: "idle" | "saving" | "success" | "error"
  readonly assistantOpen?: boolean
  readonly composerOpen?: boolean
  readonly toolbarActions?: ReactNode
}) {
  const compact = variant === "compact"
  const size: ControlSize = compact ? "icon-xs" : "icon"
  const strokeWidth = compact ? 2 : undefined
  const trigger = (active: boolean) => controlTrigger(active, size)
  const state = useEditorState({
    editor,
    selector: ({ editor: currentEditor }) => {
      const heading = headingLevels.find((level) =>
        currentEditor.isActive("heading", { level })
      )
      const cell = currentEditor.isActive("tableHeader")
        ? "tableHeader"
        : "tableCell"

      return {
        block: heading ? `heading-${heading}` : "paragraph",
        fontSize: currentEditor.getAttributes("textStyle").fontSize ?? null,
        bold: currentEditor.isActive("bold"),
        italic: currentEditor.isActive("italic"),
        underline: currentEditor.isActive("underline"),
        strike: currentEditor.isActive("strike"),
        code: currentEditor.isActive("code"),
        textColor: currentEditor.getAttributes("textStyle").color ?? null,
        backgroundColor:
          currentEditor.getAttributes("textStyle").backgroundColor ?? null,
        bulletList: currentEditor.isActive("bulletList"),
        orderedList: currentEditor.isActive("orderedList"),
        taskList: currentEditor.isActive("taskList"),
        blockquote: currentEditor.isActive("blockquote"),
        codeBlock: currentEditor.isActive("codeBlock"),
        inTable: currentEditor.isActive("table"),
        headerRow: currentEditor.isActive("tableHeader"),
        cellAlign: currentEditor.getAttributes(cell).align ?? "left",
        canUndo: currentEditor.can().undo(),
        canRedo: currentEditor.can().redo(),
      }
    },
  })
  const blockLabel =
    state.block === "paragraph"
      ? "Paragraph"
      : `Heading ${state.block.slice(-1)}`
  const parsedFontSize = state.fontSize
    ? String(Number.parseInt(state.fontSize, 10))
    : null
  const fontSize =
    parsedFontSize && fontSizeOptions.some((size) => size === parsedFontSize)
      ? parsedFontSize
      : "auto"
  const saveLabel =
    saveStatus === "saving"
      ? "Saving…"
      : saveStatus === "success"
        ? "Saved"
        : saveStatus === "error"
          ? "Save failed"
          : "Save"

  function setBlock(value: string | null) {
    if (value === "paragraph") {
      editor.chain().focus().setParagraph().run()
      return
    }

    const level = headingLevels.find((level) => value === `heading-${level}`)
    if (level) editor.chain().focus().setHeading({ level }).run()
  }

  function setFontSize(value: string | null) {
    if (!value || value === "auto") {
      editor.chain().focus().unsetFontSize().run()
      return
    }

    editor.chain().focus().setFontSize(`${value}px`).run()
  }

  const historyControls = [
    {
      label: "Undo",
      icon: UndoIcon,
      disabled: !state.canUndo,
      onClick: () => editor.chain().focus().undo().run(),
    },
    {
      label: "Redo",
      icon: RedoIcon,
      disabled: !state.canRedo,
      onClick: () => editor.chain().focus().redo().run(),
    },
  ]
  const inlineControls = [
    {
      label: "Bold",
      icon: TextBoldIcon,
      active: state.bold,
      onClick: () => editor.chain().focus().toggleBold().run(),
    },
    {
      label: "Italic",
      icon: TextItalicIcon,
      active: state.italic,
      onClick: () => editor.chain().focus().toggleItalic().run(),
    },
    {
      label: "Underline",
      icon: TextUnderlineIcon,
      active: state.underline,
      onClick: () => editor.chain().focus().toggleUnderline().run(),
    },
    {
      label: "Strikethrough",
      icon: TextStrikethroughIcon,
      active: state.strike,
      onClick: () => editor.chain().focus().toggleStrike().run(),
    },
    {
      label: "Inline code",
      icon: CodeSimpleIcon,
      active: state.code,
      onClick: () => editor.chain().focus().toggleCode().run(),
    },
  ]
  const colorControls = [
    {
      label: "Highlight",
      icon: HighlighterIcon,
      active: Boolean(state.backgroundColor),
      onClick: () => {
        const chain = editor.chain().focus()
        if (state.backgroundColor) chain.unsetBackgroundColor().run()
        else chain.setBackgroundColor(highlightColor).run()
      },
    },
    {
      label: "Clear formatting",
      icon: TextClearIcon,
      onClick: () => editor.chain().focus().unsetAllMarks().clearNodes().run(),
    },
  ]
  const blockControls = [
    {
      label: "Bullet list",
      icon: LeftToRightListBulletIcon,
      active: state.bulletList,
      onClick: () => editor.chain().focus().toggleBulletList().run(),
    },
    {
      label: "Numbered list",
      icon: LeftToRightListNumberIcon,
      active: state.orderedList,
      onClick: () => editor.chain().focus().toggleOrderedList().run(),
    },
    {
      label: "Task list",
      icon: CheckListIcon,
      active: state.taskList,
      onClick: () => editor.chain().focus().toggleTaskList().run(),
    },
    {
      label: "Blockquote",
      icon: LeftToRightBlockQuoteIcon,
      active: state.blockquote,
      onClick: () => editor.chain().focus().toggleBlockquote().run(),
    },
    {
      label: "Code block",
      icon: SourceCodeIcon,
      active: state.codeBlock,
      onClick: () => editor.chain().focus().toggleCodeBlock().run(),
    },
  ]
  const insertControls = state.inTable
    ? [
        {
          label: "Add row below",
          icon: InsertRowDownIcon,
          onClick: () => editor.chain().focus().addRowAfter().run(),
        },
        {
          label: "Delete row",
          icon: DeleteRowIcon,
          onClick: () => editor.chain().focus().deleteRow().run(),
        },
        {
          label: "Add column right",
          icon: InsertColumnRightIcon,
          onClick: () => editor.chain().focus().addColumnAfter().run(),
        },
        {
          label: "Delete column",
          icon: DeleteColumnIcon,
          onClick: () => editor.chain().focus().deleteColumn().run(),
        },
        {
          label: "Toggle header row",
          icon: TableRowsSplitIcon,
          active: state.headerRow,
          onClick: () => editor.chain().focus().toggleHeaderRow().run(),
        },
        {
          label: "Align left",
          icon: AlignLeftIcon,
          active: state.cellAlign === "left",
          onClick: () =>
            editor.chain().focus().setCellAttribute("align", "left").run(),
        },
        {
          label: "Align center",
          icon: AlignHorizontalCenterIcon,
          active: state.cellAlign === "center",
          onClick: () =>
            editor.chain().focus().setCellAttribute("align", "center").run(),
        },
        {
          label: "Align right",
          icon: AlignRightIcon,
          active: state.cellAlign === "right",
          onClick: () =>
            editor.chain().focus().setCellAttribute("align", "right").run(),
        },
        {
          label: "Delete table",
          icon: Delete02Icon,
          onClick: () => editor.chain().focus().deleteTable().run(),
        },
      ]
    : [
        {
          label: "Horizontal rule",
          icon: SeparatorHorizontalIcon,
          onClick: () => editor.chain().focus().setHorizontalRule().run(),
        },
        {
          label: "Insert table",
          icon: GridTableIcon,
          onClick: () =>
            editor
              .chain()
              .focus()
              .insertTable({ rows: 3, cols: 3, withHeaderRow: true })
              .run(),
        },
        ...(editor.schema.nodes.mention
          ? [
              {
                label: "Mention",
                icon: AtIcon,
                onClick: () => editor.chain().focus().insertContent("@").run(),
              },
            ]
          : []),
      ]
  const controlProps = { size, strokeWidth }

  return (
    <div
      className="emend-editor__toolbar"
      data-variant={variant}
      role="toolbar"
      aria-label="Editor formatting"
    >
      <EditorToolbarScroller size={size}>
        <ControlGroup
          label="History"
          controls={historyControls}
          {...controlProps}
        />
        <Separator orientation="vertical" className="emend-editor__separator" />

        <div className="emend-editor__toolbar-section">
          <Select value={state.block} onValueChange={setBlock}>
            <SelectTrigger
              size="sm"
              className={cn(
                "emend-editor__block-type",
                compact && "h-6! px-2! text-xs [&_svg]:size-3!"
              )}
              aria-label={`Block type: ${blockLabel}`}
            >
              <HugeiconsIcon icon={HeadingIcon} strokeWidth={strokeWidth} />
              <SelectValue>{blockLabel}</SelectValue>
            </SelectTrigger>
            <SelectContent align="start">
              <SelectGroup>
                <SelectItem value="paragraph">Paragraph</SelectItem>
                {headingLevels.map((level) => (
                  <SelectItem key={level} value={`heading-${level}`}>
                    Heading {level}
                  </SelectItem>
                ))}
              </SelectGroup>
            </SelectContent>
          </Select>
          <Select value={fontSize} onValueChange={setFontSize}>
            <SelectTrigger
              size="sm"
              className={cn(
                "emend-editor__font-size",
                compact && "h-6! px-2! text-xs [&_svg]:size-3!"
              )}
              aria-label={
                fontSize === "auto"
                  ? "Font size: Automatic"
                  : `Font size: ${fontSize} pixels`
              }
            >
              <HugeiconsIcon icon={ALargeSmallIcon} strokeWidth={strokeWidth} />
              <SelectValue>
                {fontSize === "auto" ? "Auto" : fontSize}
              </SelectValue>
            </SelectTrigger>
            <SelectContent align="start">
              <SelectGroup>
                <SelectItem value="auto">Auto</SelectItem>
                {fontSizeOptions.map((size) => (
                  <SelectItem key={size} value={size}>
                    {size} px
                  </SelectItem>
                ))}
              </SelectGroup>
            </SelectContent>
          </Select>
        </div>

        <Separator orientation="vertical" className="emend-editor__separator" />

        <ControlGroup
          label="Text formatting"
          controls={inlineControls}
          {...controlProps}
        >
          <EditorLinkControl
            editor={editor}
            trigger={trigger}
            strokeWidth={strokeWidth}
          />
          <EditorColorControl
            editor={editor}
            type="text"
            color={state.textColor}
            trigger={trigger}
            strokeWidth={strokeWidth}
          />
          <EditorColorControl
            editor={editor}
            type="background"
            color={state.backgroundColor}
            trigger={trigger}
            strokeWidth={strokeWidth}
          />
        </ControlGroup>

        <ControlGroup
          label="Highlight and cleanup"
          controls={colorControls}
          {...controlProps}
        />

        <Separator orientation="vertical" className="emend-editor__separator" />

        <ControlGroup
          label="Lists and blocks"
          controls={blockControls}
          {...controlProps}
        />

        <Separator orientation="vertical" className="emend-editor__separator" />

        <ControlGroup
          label={state.inTable ? "Table" : "Insert"}
          controls={insertControls}
          {...controlProps}
        >
          {!state.inTable && (
            <EditorImageControl
              editor={editor}
              trigger={trigger}
              strokeWidth={strokeWidth}
              onUpload={onImageUpload}
            />
          )}
        </ControlGroup>

        <Separator orientation="vertical" className="emend-editor__separator" />

        <div
          className="emend-editor__toolbar-group"
          role="group"
          aria-label="Search"
        >
          <EditorFindReplace
            editor={editor}
            trigger={trigger}
            strokeWidth={strokeWidth}
          />
        </div>
      </EditorToolbarScroller>

      {(toolbarActions || onAssistantToggle || onComposerToggle || onSave) && (
        <div className="emend-editor__toolbar-actions">
          {toolbarActions}
          {onAssistantToggle && (
            <Tooltip>
              <TooltipTrigger
                render={
                  <Button
                    type="button"
                    variant="outline"
                    size={size}
                    aria-label="AI Assistant"
                    aria-expanded={assistantOpen}
                    onClick={onAssistantToggle}
                  />
                }
              >
                <HugeiconsIcon icon={AiChat02Icon} strokeWidth={strokeWidth} />
              </TooltipTrigger>
              <TooltipContent>AI Assistant</TooltipContent>
            </Tooltip>
          )}
          {onComposerToggle && (
            <Tooltip>
              <TooltipTrigger
                render={
                  <Button
                    type="button"
                    variant="outline"
                    size={size}
                    aria-label="AI Composer"
                    aria-pressed={composerOpen}
                    onClick={onComposerToggle}
                  />
                }
              >
                <HugeiconsIcon icon={AiEditingIcon} strokeWidth={strokeWidth} />
              </TooltipTrigger>
              <TooltipContent>AI Composer</TooltipContent>
            </Tooltip>
          )}
          {onSave && (
            <Button
              type="button"
              size={compact ? "xs" : "default"}
              disabled={saveDisabled}
              aria-live="polite"
              aria-busy={saveStatus === "saving"}
              onClick={() => void onSave()}
            >
              {saveLabel}
            </Button>
          )}
        </div>
      )}
    </div>
  )
}

interface EditorControlProps {
  readonly label: string
  readonly icon: IconSvgElement
  readonly active?: boolean
  readonly disabled?: boolean
  readonly size?: ControlSize
  readonly strokeWidth?: number
  readonly onClick: () => void
}

function ControlGroup({
  label,
  controls,
  children,
  size,
  strokeWidth,
}: {
  readonly label: string
  readonly controls: readonly EditorControlProps[]
  readonly children?: ReactNode
  readonly size: ControlSize
  readonly strokeWidth?: number
}) {
  return (
    <div
      className="emend-editor__toolbar-group"
      role="group"
      aria-label={label}
    >
      {controls.map((control) => (
        <EditorControl
          key={control.label}
          size={size}
          strokeWidth={strokeWidth}
          {...control}
        />
      ))}
      {children}
    </div>
  )
}

export function controlTrigger(active: boolean, size: ControlSize = "icon") {
  return (
    <Button
      type="button"
      variant="ghost"
      size={size}
      className={cn("relative", active && "bg-muted text-foreground")}
    />
  )
}

export function EditorControl({
  label,
  icon,
  active,
  disabled,
  size = "icon",
  strokeWidth,
  onClick,
}: EditorControlProps) {
  return (
    <Tooltip>
      <TooltipTrigger
        render={
          <Button
            type="button"
            variant="ghost"
            size={size}
            className={cn(active && "bg-muted text-foreground")}
            aria-label={label}
            aria-pressed={active}
            disabled={disabled}
            onMouseDown={(event) => event.preventDefault()}
            onClick={onClick}
          >
            <HugeiconsIcon icon={icon} strokeWidth={strokeWidth} />
          </Button>
        }
      />
      <TooltipContent>{label}</TooltipContent>
    </Tooltip>
  )
}
```

### `registry/blocks/emend-editor/emend-editor.css`

```css
.emend-editor {
  container: emend-editor / inline-size;
  display: flex;
  width: 100%;
  height: 100%;
  max-width: 100%;
  min-width: 0;
  flex-direction: column;
  overflow: hidden;
  background: var(--background);
  color: var(--foreground);
  transition: box-shadow 150ms ease;
}

.emend-editor__toolbar {
  position: sticky;
  top: 0;
  z-index: 2;
  display: flex;
  min-width: 0;
  flex-shrink: 0;
  align-items: center;
  gap: 0.25rem;
  padding: 0.375rem;
  border-bottom: 1px solid var(--border);
  background: color-mix(in oklch, var(--background) 94%, var(--muted));
}

.emend-editor__toolbar-group,
.emend-editor__toolbar-section,
.emend-editor__toolbar-actions {
  display: flex;
  flex-shrink: 0;
  align-items: center;
  gap: 0.25rem;
}

.emend-editor__toolbar-group,
.emend-editor__toolbar-section {
  padding-inline: 0.375rem;
}

.emend-editor__separator {
  height: 1.25rem;
  align-self: center;
}

.emend-editor__block-type {
  width: 7.5rem;
}

.emend-editor__font-size {
  width: 5.5rem;
}

.emend-editor__toolbar[data-variant="compact"] {
  gap: 0.125rem;
  padding-inline: 0.25rem;
}

.emend-editor__toolbar[data-variant="compact"] .emend-editor__toolbar-group,
.emend-editor__toolbar[data-variant="compact"] .emend-editor__toolbar-section,
.emend-editor__toolbar[data-variant="compact"] .emend-editor__toolbar-actions {
  gap: 0.125rem;
  padding-inline: 0.25rem;
}

.emend-editor__toolbar[data-variant="compact"] .emend-editor__separator {
  height: 0.75rem;
}

.emend-editor__toolbar[data-variant="compact"] .emend-editor__block-type {
  width: 6.5rem;
}

.emend-editor__toolbar[data-variant="compact"] .emend-editor__font-size {
  width: 5rem;
}

.emend-editor__workspace,
.emend-editor__main,
.emend-editor__content {
  min-width: 0;
  min-height: 0;
}

.emend-editor__workspace {
  position: relative;
  flex: 1;
}

.emend-editor__main {
  position: relative;
  display: flex;
  height: 100%;
  flex-direction: column;
}

.emend-editor__main--ai {
  padding-bottom: 3.625rem;
}

.emend-editor__content {
  flex: 1;
  overflow-x: hidden;
  overflow-y: auto;
}

.emend-editor__ai-tools {
  position: absolute;
  right: 0;
  bottom: 1rem;
  left: 0;
  z-index: 2;
  width: min(100% - 2rem, 36rem);
  margin: 0 auto;
}

.emend-editor .emend-ai-target {
  border-radius: 0.25rem;
  background: color-mix(in oklch, var(--primary) 14%, transparent);
  box-decoration-break: clone;
  -webkit-box-decoration-break: clone;
}

.emend-editor .emend-editor__mention {
  border-radius: 0.25rem;
  background: color-mix(in oklch, var(--primary) 12%, transparent);
  padding: 0.1rem 0.3rem;
  color: var(--primary);
  font-weight: 500;
}

.emend-editor .find-and-replace-result {
  border-radius: 0.15rem;
  background: color-mix(in oklch, var(--chart-1) 45%, transparent);
}

.emend-editor .find-and-replace-result-current {
  background: color-mix(in oklch, var(--chart-2) 75%, transparent);
}

.emend-editor [data-emend-preview="true"] {
  margin-inline: 0.25rem;
  border-radius: 0.375rem;
  background: color-mix(in oklch, var(--chart-2) 20%, transparent);
  opacity: 0.8;
}

.emend-editor .ProseMirror {
  min-height: 32rem;
  max-width: 52rem;
  margin-inline: auto;
  padding: clamp(2rem, 6vw, 4rem) clamp(1.25rem, 5vw, 3rem);
  outline: none;
  overflow-wrap: anywhere;
}

.emend-editor .ProseMirror > * + * {
  margin-top: 0.75rem;
}

.emend-editor .ProseMirror h1 {
  font-size: 2rem;
  font-weight: 650;
  line-height: 1.2;
}

.emend-editor .ProseMirror h2 {
  margin-top: 1.75rem;
  font-size: 1.5rem;
  font-weight: 650;
  line-height: 1.3;
}

.emend-editor .ProseMirror h3 {
  margin-top: 1.5rem;
  font-size: 1.25rem;
  font-weight: 650;
}

.emend-editor .ProseMirror h4,
.emend-editor .ProseMirror h5,
.emend-editor .ProseMirror h6 {
  margin-top: 1.25rem;
  font-weight: 650;
}

.emend-editor .ProseMirror blockquote {
  border-left: 3px solid var(--border);
  padding-left: 1rem;
  color: var(--muted-foreground);
}

.emend-editor .ProseMirror ul,
.emend-editor .ProseMirror ol {
  padding-left: 1.5rem;
}

.emend-editor .ProseMirror ul {
  list-style: disc;
}

.emend-editor .ProseMirror ol {
  list-style: decimal;
}

.emend-editor .ProseMirror li + li {
  margin-top: 0.25rem;
}

.emend-editor .ProseMirror ul[data-type="taskList"] {
  padding-left: 0;
  list-style: none;
}

.emend-editor .ProseMirror ul[data-type="taskList"] > li {
  display: flex;
  gap: 0.5rem;
}

.emend-editor .ProseMirror ul[data-type="taskList"] > li > div {
  flex: 1;
}

.emend-editor .ProseMirror a {
  color: var(--primary);
  text-decoration: underline;
  text-underline-offset: 0.2em;
}

.emend-editor .ProseMirror code {
  border-radius: 0.25rem;
  background: var(--muted);
  padding: 0.125rem 0.3rem;
  font-family: var(--font-mono);
  font-size: 0.9em;
}

.emend-editor .ProseMirror pre {
  overflow-x: auto;
  border-radius: calc(var(--radius) * 0.8);
  background: var(--muted);
  padding: 1rem;
}

.emend-editor .ProseMirror pre code {
  padding: 0;
}

.emend-editor .ProseMirror hr {
  margin-block: 2rem;
  border-color: var(--border);
}

.emend-editor .ProseMirror img {
  display: block;
  max-width: 100%;
  height: auto;
  border-radius: calc(var(--radius) * 0.8);
}

.emend-editor .ProseMirror img.ProseMirror-selectednode {
  outline: 2px solid var(--ring);
  outline-offset: 2px;
}

.emend-editor .ProseMirror .tableWrapper {
  overflow-x: auto;
}

.emend-editor .ProseMirror table {
  width: 100%;
  border-collapse: collapse;
}

.emend-editor .ProseMirror th,
.emend-editor .ProseMirror td {
  min-width: 7rem;
  padding: 0.5rem 0.75rem;
  border: 1px solid var(--border);
  text-align: left;
  vertical-align: top;
}

.emend-editor .ProseMirror th {
  background: var(--muted);
  font-weight: 600;
}

.emend-editor .ProseMirror p.is-editor-empty:first-child::before {
  float: left;
  height: 0;
  color: var(--muted-foreground);
  content: attr(data-placeholder);
  pointer-events: none;
}

.emend-editor__footer {
  display: flex;
  flex-shrink: 0;
  justify-content: space-between;
  gap: 1rem;
  padding: 0.5rem 0.75rem;
  border-top: 1px solid var(--border);
  color: var(--muted-foreground);
  font-size: 0.75rem;
}
```

### `registry/blocks/emend-editor/extensions.ts`

```ts
import {
  getText,
  getTextSerializersFromSchema,
  type Editor,
  type Extensions,
  type JSONContent,
} from "@tiptap/core"
import { FindAndReplace } from "@tiptap/extension-find-and-replace"
import { Image } from "@tiptap/extension-image"
import { TaskItem, TaskList } from "@tiptap/extension-list"
import { TableKit } from "@tiptap/extension-table"
import { TextStyleKit } from "@tiptap/extension-text-style"
import { CharacterCount, Placeholder } from "@tiptap/extensions"
import { Markdown } from "@tiptap/markdown"
import StarterKit from "@tiptap/starter-kit"
import {
  createEditorMention,
  editorMentionItems,
  type EditorMentionItem,
} from "./editor-mention"

export function createEmendEditorExtensions(
  placeholder = "Start writing…",
  mentionItems: readonly EditorMentionItem[] = editorMentionItems
): Extensions {
  return [
    StarterKit.configure({
      link: { openOnClick: false, enableClickSelection: true },
    }),
    TaskList,
    TaskItem.configure({ nested: true }),
    TableKit,
    Image.configure({ allowBase64: true }),
    TextStyleKit.configure({ fontFamily: false, lineHeight: false }),
    FindAndReplace.configure({ injectCSS: false, searchDebounceMs: 0 }),
    Markdown.configure({ markedOptions: { gfm: true } }),
    Placeholder.configure({ placeholder }),
    CharacterCount,
    ...(mentionItems.length > 0 ? [createEditorMention(mentionItems)] : []),
  ]
}

export function createEmendContextProjection(editor: Editor) {
  const textSerializers = getTextSerializersFromSchema(editor.schema)
  return (context: JSONContent) =>
    getText(editor.schema.nodeFromJSON(context), {
      blockSeparator: "\n\n",
      textSerializers,
    })
}
```

### `registry/blocks/emend-editor/emend-editor-base.tsx`

```tsx
"use client"

import type {
  Editor,
  EditorOptions,
  Extensions,
  JSONContent,
} from "@tiptap/core"
import { EditorContent, useEditor } from "@tiptap/react"
import {
  useEffect,
  useMemo,
  useRef,
  useState,
  type KeyboardEvent,
  type ReactNode,
} from "react"
import { EditorBubbleMenu } from "./editor-bubble-menu"
import type { EditorImageUpload } from "./editor-image-control"
import type { EditorMentionItem } from "./editor-mention"
import { EditorToolbar, type EditorToolbarVariant } from "./editor-toolbar"
import { createEmendEditorExtensions } from "./extensions"
import "./emend-editor.css"

export interface EmendEditorBaseProps {
  readonly initialContent?: JSONContent
  readonly placeholder?: string
  readonly editable?: boolean
  readonly extensions?: Extensions
  readonly mentionItems?: readonly EditorMentionItem[]
  readonly editorProps?: EditorOptions["editorProps"]
  readonly toolbarActions?: ReactNode
  readonly toolbarVariant?: EditorToolbarVariant
  readonly onImageUpload?: EditorImageUpload
  readonly onChange?: (content: {
    readonly json: JSONContent
    readonly html: string
  }) => void
  readonly onEditorReady?: (editor: Editor) => void
  readonly onSave?: (editor: Editor) => void | Promise<void>
  readonly renderWorkspace?: (editor: Editor, content: ReactNode) => ReactNode
  readonly onAssistantToggle?: () => void
  readonly assistantOpen?: boolean
  readonly onComposerToggle?: () => void
  readonly composerOpen?: boolean
}

export function EmendEditorBase({
  initialContent,
  placeholder = "Start writing…",
  editable = true,
  extensions,
  mentionItems,
  editorProps,
  toolbarActions,
  toolbarVariant = "compact",
  onImageUpload,
  onChange,
  onEditorReady,
  onSave,
  renderWorkspace,
  onAssistantToggle,
  assistantOpen,
  onComposerToggle,
  composerOpen,
}: EmendEditorBaseProps) {
  const [characterCount, setCharacterCount] = useState(0)
  const [saveStatus, setSaveStatus] = useState<
    "idle" | "saving" | "success" | "error"
  >("idle")
  const documentVersion = useRef(0)
  const saveInFlight = useRef(false)
  const configuredExtensions = useMemo(
    () => [
      ...createEmendEditorExtensions(placeholder, mentionItems),
      ...(extensions ?? []),
    ],
    [extensions, mentionItems, placeholder]
  )
  const editor = useEditor({
    content: initialContent,
    extensions: configuredExtensions,
    editorProps: editorProps ?? {},
    editable,
    immediatelyRender: false,
    onCreate: ({ editor: currentEditor }) => {
      setCharacterCount(currentEditor.storage.characterCount.characters())
      onEditorReady?.(currentEditor)
    },
    onUpdate: ({ editor: currentEditor }) => {
      documentVersion.current += 1
      setCharacterCount(currentEditor.storage.characterCount.characters())
      if (!saveInFlight.current) setSaveStatus("idle")
      onChange?.({
        json: currentEditor.getJSON(),
        html: currentEditor.getHTML(),
      })
    },
  })

  useEffect(() => {
    if (editor && editor.isEditable !== editable) {
      editor.setEditable(editable, false)
    }
  }, [editable, editor])

  async function save() {
    if (!editor || !onSave || !editable || saveInFlight.current) return

    const savedVersion = documentVersion.current
    saveInFlight.current = true
    setSaveStatus("saving")
    try {
      await onSave(editor)
      if (savedVersion === documentVersion.current) setSaveStatus("success")
    } catch {
      if (savedVersion === documentVersion.current) setSaveStatus("error")
    } finally {
      saveInFlight.current = false
      if (savedVersion !== documentVersion.current) setSaveStatus("idle")
    }
  }

  function handleKeyDown(event: KeyboardEvent<HTMLElement>) {
    if (
      onSave &&
      editable &&
      (event.metaKey || event.ctrlKey) &&
      event.key.toLowerCase() === "s"
    ) {
      event.preventDefault()
      void save()
    }
  }

  return (
    <section className="emend-editor" onKeyDown={handleKeyDown}>
      {editor && editable && (
        <EditorToolbar
          editor={editor}
          variant={toolbarVariant}
          onImageUpload={onImageUpload}
          onSave={onSave ? save : undefined}
          onAssistantToggle={onAssistantToggle}
          saveDisabled={saveStatus === "saving"}
          saveStatus={saveStatus}
          assistantOpen={assistantOpen}
          onComposerToggle={onComposerToggle}
          composerOpen={composerOpen}
          toolbarActions={toolbarActions}
        />
      )}
      {editor && editable && renderWorkspace ? (
        renderWorkspace(editor, <EditorContent editor={editor} />)
      ) : (
        <div className="emend-editor__workspace">
          <div className="emend-editor__main">
            <div className="emend-editor__content">
              <EditorContent editor={editor} />
            </div>
            {editor && editable && <EditorBubbleMenu editor={editor} />}
          </div>
        </div>
      )}
      <footer className="emend-editor__footer">
        <span>{editor ? (editable ? "Ready" : "Read only") : "Loading…"}</span>
        {editor && <span>{characterCount} characters</span>}
      </footer>
    </section>
  )
}
```

### `packages/ui/src/lib/utils.ts`

```ts
/*
 * Copyright (c) 2023 shadcn.
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 * THE SOFTWARE.
 */

import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}
```
