Markdown Editor component
Install
npx shadcn add https://ui.happenings.social/r/markdown-editor.json
Or with namespace
npx shadcn add @happenings/markdown-editor
Preview
Rich text editor powered by Tiptap with markdown serialization. Supports mentions, tables, and links.
Dependencies
Registry dependencies
src/components/ui/markdown-editor.tsx
"use client"
import * as React from "react"
import { useEditor, EditorContent, type Editor } from "@tiptap/react"
import StarterKit from "@tiptap/starter-kit"
import LinkExtension from "@tiptap/extension-link"
import PlaceholderExtension from "@tiptap/extension-placeholder"
import { Table as TableExtension } from "@tiptap/extension-table"
import TableRow from "@tiptap/extension-table-row"
import TableCell from "@tiptap/extension-table-cell"
import TableHeader from "@tiptap/extension-table-header"
import { Markdown } from "tiptap-markdown"
import MarkdownIt from "markdown-it"
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
import {
faAt,
faBold,
faBuilding,
faCode,
faHeading,
faItalic,
faLink,
faLinkSlash,
faListOl,
faListUl,
faQuoteLeft,
faTable,
faUser,
} from "@fortawesome/pro-solid-svg-icons"
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core"
import { cn } from "@/lib/utils"
// ---------------------------------------------------------------------------
// Types (backwards-compatible)
// ---------------------------------------------------------------------------
type MarkdownEditorProps = {
value?: string
onChange?: (value: string) => void
placeholder?: string
disabled?: boolean
rows?: number
className?: string
toolbarExtras?: React.ReactNode
mentionSuggestions?: MarkdownMentionUser[]
onMentionsChange?: (ids: string[]) => void
onMentionQueryChange?: (query: string | null) => void
onBlur?: (...args: unknown[]) => void
name?: string
maxLength?: number
id?: string
"data-testid"?: string
/** "compact" hides the toolbar until focused and shows only essential formatting. */
variant?: "default" | "compact"
/** Extra content rendered at the bottom of the editor (e.g. a send button). */
actions?: React.ReactNode
// Kept for backwards compat — ignored in WYSIWYG mode
emptyPreviewLabel?: string
previewClassName?: string
writeLabel?: string
previewLabel?: string
}
export interface MarkdownEditorRef {
focus: () => void
insertMarkdown: (markdown: string) => void
}
export interface MarkdownMentionUser {
id: string
displayName: string
avatarUrl?: string
mentionText?: string
subtitle?: string
kind?: "user" | "page"
}
// ---------------------------------------------------------------------------
// Markdown-to-HTML parser (for insertMarkdown ref method)
// ---------------------------------------------------------------------------
const mdParser = new MarkdownIt()
function looksLikeUrl(text: string): boolean {
const trimmed = text.trim()
if (!trimmed || trimmed.includes("\n")) return false
try {
const url = new URL(trimmed)
return url.protocol === "http:" || url.protocol === "https:"
} catch {
return false
}
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
}
function mentionTextFor(user: MarkdownMentionUser): string {
return (user.mentionText || user.displayName).trim()
}
function getMentionedUserIdsFromText(
text: string,
suggestions: MarkdownMentionUser[],
): string[] {
if (!text || suggestions.length === 0) return []
const seen = new Set<string>()
for (const user of suggestions) {
if (!user.id || user.kind === "page") continue
const mentionText = mentionTextFor(user)
if (!mentionText) continue
const pattern = new RegExp(
`(^|\\s)@${escapeRegExp(mentionText)}(?=$|[\\s.,!?;:)\\]}])`,
"i",
)
if (pattern.test(text)) {
seen.add(user.id)
}
}
return [...seen]
}
function getMarkdownFromEditor(editor: Editor): string {
// tiptap-markdown stores getMarkdown on editor.storage.markdown
// but the types aren't declared, so we access via Record cast
const storage = editor.storage as unknown as Record<string, unknown>
const mdStorage = storage["markdown"] as { getMarkdown: () => string } | undefined
return mdStorage?.getMarkdown() ?? ""
}
// ---------------------------------------------------------------------------
// Toolbar
// ---------------------------------------------------------------------------
interface ToolbarBtn {
icon: IconDefinition
label: string
action: (editor: Editor) => void
isActive?: (editor: Editor) => boolean
}
const TOOLBAR_GROUPS: ToolbarBtn[][] = [
// Text formatting
[
{
icon: faHeading,
label: "Heading",
action: (e) => e.chain().focus().toggleHeading({ level: 2 }).run(),
isActive: (e) => e.isActive("heading"),
},
{
icon: faBold,
label: "Bold",
action: (e) => e.chain().focus().toggleBold().run(),
isActive: (e) => e.isActive("bold"),
},
{
icon: faItalic,
label: "Italic",
action: (e) => e.chain().focus().toggleItalic().run(),
isActive: (e) => e.isActive("italic"),
},
{
icon: faLink,
label: "Link (⌘K)",
// Action is handled via onLinkAction callback — see component
action: () => {},
isActive: (e) => e.isActive("link"),
},
],
// Lists
[
{
icon: faListUl,
label: "Bullet list",
action: (e) => e.chain().focus().toggleBulletList().run(),
isActive: (e) => e.isActive("bulletList"),
},
{
icon: faListOl,
label: "Numbered list",
action: (e) => e.chain().focus().toggleOrderedList().run(),
isActive: (e) => e.isActive("orderedList"),
},
],
// Blocks
[
{
icon: faQuoteLeft,
label: "Quote",
action: (e) => e.chain().focus().toggleBlockquote().run(),
isActive: (e) => e.isActive("blockquote"),
},
{
icon: faCode,
label: "Code block",
action: (e) => e.chain().focus().toggleCodeBlock().run(),
isActive: (e) => e.isActive("codeBlock"),
},
{
icon: faTable,
label: "Table",
action: (e) =>
e
.chain()
.focus()
.insertTable({ rows: 2, cols: 2, withHeaderRow: true })
.run(),
},
],
]
/** Minimal formatting options for compact/comment mode. */
const COMPACT_TOOLBAR_GROUPS: ToolbarBtn[][] = [
[
TOOLBAR_GROUPS[0]![1]!, // Bold
TOOLBAR_GROUPS[0]![2]!, // Italic
TOOLBAR_GROUPS[0]![3]!, // Link
],
]
// ---------------------------------------------------------------------------
// Editor content styles
// ---------------------------------------------------------------------------
const editorStyles = cn(
// ProseMirror chrome
"[&_.ProseMirror]:outline-none [&_.ProseMirror]:px-4 [&_.ProseMirror]:py-3 [&_.ProseMirror]:min-h-[inherit]",
// Placeholder
"[&_.ProseMirror_p.is-empty:first-child::before]:content-[attr(data-placeholder)]",
"[&_.ProseMirror_p.is-empty:first-child::before]:text-muted-foreground",
"[&_.ProseMirror_p.is-empty:first-child::before]:pointer-events-none",
"[&_.ProseMirror_p.is-empty:first-child::before]:float-left",
"[&_.ProseMirror_p.is-empty:first-child::before]:h-0",
// Typography
"text-sm text-foreground",
"[&_.ProseMirror>*:first-child]:mt-0 [&_.ProseMirror>*:last-child]:mb-0",
"[&_.ProseMirror_p]:my-2 [&_.ProseMirror_p]:leading-relaxed",
"[&_.ProseMirror_strong]:font-semibold",
"[&_.ProseMirror_a]:underline [&_.ProseMirror_a]:decoration-zinc-400 [&_.ProseMirror_a]:underline-offset-4 hover:[&_.ProseMirror_a]:decoration-zinc-700",
// Headings
"[&_.ProseMirror_h2]:mt-4 [&_.ProseMirror_h2]:text-lg [&_.ProseMirror_h2]:font-semibold",
"[&_.ProseMirror_h3]:mt-3 [&_.ProseMirror_h3]:text-base [&_.ProseMirror_h3]:font-semibold",
"[&_.ProseMirror_h4]:mt-3 [&_.ProseMirror_h4]:text-sm [&_.ProseMirror_h4]:font-semibold",
// Lists
"[&_.ProseMirror_ul]:my-2 [&_.ProseMirror_ul]:list-disc [&_.ProseMirror_ul]:pl-5",
"[&_.ProseMirror_ol]:my-2 [&_.ProseMirror_ol]:list-decimal [&_.ProseMirror_ol]:pl-5",
"[&_.ProseMirror_li]:my-0.5",
// Blockquote
"[&_.ProseMirror_blockquote]:my-3 [&_.ProseMirror_blockquote]:border-l-2 [&_.ProseMirror_blockquote]:border-zinc-300 [&_.ProseMirror_blockquote]:pl-4 [&_.ProseMirror_blockquote]:italic dark:[&_.ProseMirror_blockquote]:border-zinc-700",
// Code
"[&_.ProseMirror_code]:rounded-md [&_.ProseMirror_code]:bg-zinc-100 [&_.ProseMirror_code]:px-1.5 [&_.ProseMirror_code]:py-0.5 [&_.ProseMirror_code]:font-mono [&_.ProseMirror_code]:text-[0.92em] dark:[&_.ProseMirror_code]:bg-zinc-900",
"[&_.ProseMirror_pre]:my-3 [&_.ProseMirror_pre]:overflow-x-auto [&_.ProseMirror_pre]:rounded-xl [&_.ProseMirror_pre]:bg-zinc-950 [&_.ProseMirror_pre]:p-3 [&_.ProseMirror_pre]:text-zinc-50 dark:[&_.ProseMirror_pre]:bg-zinc-900",
"[&_.ProseMirror_pre_code]:bg-transparent [&_.ProseMirror_pre_code]:p-0",
// Table
"[&_.ProseMirror_table]:my-3 [&_.ProseMirror_table]:w-full [&_.ProseMirror_table]:border-collapse",
"[&_.ProseMirror_td]:border [&_.ProseMirror_td]:border-zinc-200 [&_.ProseMirror_td]:px-3 [&_.ProseMirror_td]:py-2 dark:[&_.ProseMirror_td]:border-zinc-800",
"[&_.ProseMirror_th]:border [&_.ProseMirror_th]:border-zinc-200 [&_.ProseMirror_th]:bg-zinc-100 [&_.ProseMirror_th]:px-3 [&_.ProseMirror_th]:py-2 [&_.ProseMirror_th]:text-left dark:[&_.ProseMirror_th]:border-zinc-800 dark:[&_.ProseMirror_th]:bg-zinc-900",
)
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
const MarkdownEditor = React.forwardRef<MarkdownEditorRef, MarkdownEditorProps>(
function MarkdownEditor(
{
className,
disabled,
onChange,
onBlur,
placeholder,
rows = 6,
value = "",
mentionSuggestions = [],
onMentionsChange,
onMentionQueryChange,
toolbarExtras,
variant = "default",
actions,
id,
"data-testid": dataTestId,
},
forwardedRef,
) {
const isInternalChange = React.useRef(false)
const isCompact = variant === "compact"
const rawHeight = Number(rows || 0) * 24 + 24
const editorMinHeight = isCompact ? rawHeight : Math.max(rawHeight, 120)
// ----- Compact mode: hide toolbar entirely -----
const showToolbar = !isCompact
const activeToolbarGroups = isCompact ? COMPACT_TOOLBAR_GROUPS : TOOLBAR_GROUPS
// ----- Mention state -----
const [showMentions, setShowMentions] = React.useState(false)
const [mentionQuery, setMentionQuery] = React.useState("")
const [selectedIdx, setSelectedIdx] = React.useState(0)
const [mentionRange, setMentionRange] = React.useState<{
from: number
to: number
} | null>(null)
const mentionPopupRef = React.useRef<HTMLDivElement>(null)
const mentionSuggestionsRef = React.useRef(mentionSuggestions)
const onMentionsChangeRef = React.useRef(onMentionsChange)
const onMentionQueryChangeRef = React.useRef(onMentionQueryChange)
React.useEffect(() => {
mentionSuggestionsRef.current = mentionSuggestions
onMentionsChangeRef.current = onMentionsChange
onMentionQueryChangeRef.current = onMentionQueryChange
}, [mentionSuggestions, onMentionQueryChange, onMentionsChange])
const filteredMentions = React.useMemo(() => {
if (!mentionQuery.trim()) return mentionSuggestions
const q = mentionQuery.trim().toLowerCase()
return mentionSuggestions.filter((u) => {
const searchable = [u.displayName, u.mentionText, u.subtitle]
.filter(Boolean)
.join(" ")
.toLowerCase()
return searchable.includes(q)
})
}, [mentionQuery, mentionSuggestions])
// ----- Tiptap editor -----
const editor = useEditor({
immediatelyRender: false,
extensions: [
StarterKit.configure({ heading: { levels: [2, 3, 4] } }),
LinkExtension.configure({
openOnClick: false,
HTMLAttributes: { rel: "noopener noreferrer", target: "_blank" },
}),
PlaceholderExtension.configure({ placeholder: placeholder ?? "" }),
TableExtension.configure({ resizable: false }),
TableRow,
TableCell,
TableHeader,
Markdown.configure({
html: false,
transformCopiedText: true,
transformPastedText: true,
}),
],
content: value,
editable: !disabled,
editorProps: {
handlePaste(view, event) {
// Paste-to-link: when text is selected and the clipboard contains a
// URL, wrap the selection as a link instead of replacing it.
const { from, to } = view.state.selection
if (from === to) return false
const clipText = event.clipboardData?.getData("text/plain") ?? ""
if (!looksLikeUrl(clipText)) return false
event.preventDefault()
const ed = view.state.tr
ed.addMark(
from,
to,
view.state.schema.marks["link"]!.create({
href: clipText.trim(),
}),
)
view.dispatch(ed)
return true
},
},
onUpdate({ editor: ed }) {
const md = getMarkdownFromEditor(ed)
const currentMentionSuggestions = mentionSuggestionsRef.current
isInternalChange.current = true
onChange?.(md)
onMentionsChangeRef.current?.(
getMentionedUserIdsFromText(md, currentMentionSuggestions),
)
requestAnimationFrame(() => {
isInternalChange.current = false
})
// Mention detection
if (
currentMentionSuggestions.length > 0 ||
onMentionQueryChangeRef.current
) {
const { from } = ed.state.selection
const $from = ed.state.doc.resolve(from)
const textBefore = $from.parent.textContent.slice(0, $from.parentOffset)
const match = textBefore.match(/(?:^|\s)@([^\s@]*)$/)
if (match) {
const qLen = match[1]!.length
setMentionQuery(match[1]!)
setMentionRange({ from: from - qLen - 1, to: from })
setShowMentions(true)
setSelectedIdx(0)
onMentionQueryChangeRef.current?.(match[1]!)
} else {
setShowMentions(false)
setMentionQuery("")
setMentionRange(null)
onMentionQueryChangeRef.current?.(null)
}
}
},
onBlur() {
onBlur?.()
},
})
// ----- Link popover state -----
const [showLinkPopover, setShowLinkPopover] = React.useState(false)
const [linkUrl, setLinkUrl] = React.useState("")
const linkInputRef = React.useRef<HTMLInputElement>(null)
const openLinkPopover = React.useCallback(() => {
if (!editor) return
const existingHref = editor.getAttributes("link").href as string | undefined
setLinkUrl(existingHref ?? "")
setShowLinkPopover(true)
requestAnimationFrame(() => linkInputRef.current?.focus())
}, [editor])
const applyLink = React.useCallback(() => {
if (!editor) return
const trimmed = linkUrl.trim()
if (trimmed) {
editor.chain().focus().setLink({ href: trimmed }).run()
} else {
editor.chain().focus().unsetLink().run()
}
setShowLinkPopover(false)
setLinkUrl("")
}, [editor, linkUrl])
const removeLink = React.useCallback(() => {
if (!editor) return
editor.chain().focus().unsetLink().run()
setShowLinkPopover(false)
setLinkUrl("")
}, [editor])
// Sync external value changes (e.g. form reset)
React.useEffect(() => {
if (!editor || isInternalChange.current) return
const currentMd = getMarkdownFromEditor(editor)
if (currentMd !== value) {
editor.commands.setContent(value)
}
onMentionsChange?.(
getMentionedUserIdsFromText(value, mentionSuggestions),
)
}, [editor, mentionSuggestions, onMentionsChange, value])
// Sync disabled / editable
React.useEffect(() => {
editor?.setEditable(!disabled)
}, [editor, disabled])
// Close mentions on outside click
React.useEffect(() => {
if (!showMentions) return
function handleClick(e: MouseEvent) {
if (
mentionPopupRef.current &&
!mentionPopupRef.current.contains(e.target as Node)
) {
setShowMentions(false)
}
}
document.addEventListener("mousedown", handleClick)
return () => document.removeEventListener("mousedown", handleClick)
}, [showMentions])
// Insert a mention
const insertMention = React.useCallback(
(user: MarkdownMentionUser) => {
if (!editor || !mentionRange) return
const mentionText = mentionTextFor(user)
if (!mentionText) return
editor
.chain()
.focus()
.deleteRange(mentionRange)
.insertContent(`@${mentionText} `)
.run()
setShowMentions(false)
setMentionQuery("")
setMentionRange(null)
},
[editor, mentionRange],
)
// Ref API
React.useImperativeHandle(
forwardedRef,
() => ({
focus() {
editor?.commands.focus()
},
insertMarkdown(markdown: string) {
if (!editor) return
const html = mdParser.render(markdown)
editor.chain().focus().insertContent(html).run()
},
}),
[editor],
)
return (
<div
id={id}
data-testid={dataTestId}
className={cn(
"group/editor overflow-hidden rounded-xl border border-zinc-200 bg-white transition-all",
"focus-within:border-zinc-300 focus-within:ring-2 focus-within:ring-zinc-900/5",
"dark:border-zinc-800 dark:bg-zinc-900 dark:focus-within:border-zinc-700 dark:focus-within:ring-white/5",
disabled && "opacity-60",
className,
)}
>
{/* Toolbar — hidden in compact mode until focused */}
{showToolbar ? (
<div className="flex items-center gap-1 border-b border-zinc-200 bg-zinc-50 px-2.5 py-2 animate-in fade-in-0 slide-in-from-top-1 duration-150 dark:border-zinc-800 dark:bg-zinc-900/80">
<div className="flex items-center gap-1 overflow-x-auto">
{activeToolbarGroups.map((group, groupIdx) => (
<React.Fragment key={groupIdx}>
{groupIdx > 0 && (
<div className="mx-1 h-5 w-px bg-zinc-200 dark:bg-zinc-700/70" />
)}
<div className="flex items-center gap-0.5">
{group.map((btn) => {
const active = editor ? btn.isActive?.(editor) : false
return (
<button
key={btn.label}
type="button"
className={cn(
"flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-lg transition-[background-color,color,transform] active:scale-[0.94] disabled:cursor-not-allowed disabled:opacity-50",
active
? "bg-zinc-900 text-white shadow-sm dark:bg-zinc-100 dark:text-zinc-900"
: "text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100",
)}
onClick={() => {
if (!editor) return
if (btn.label.startsWith("Link")) {
openLinkPopover()
} else {
btn.action(editor)
}
}}
disabled={disabled || !editor}
title={btn.label}
aria-label={btn.label}
aria-pressed={active}
>
<FontAwesomeIcon icon={btn.icon} className="size-3.5" />
</button>
)
})}
</div>
</React.Fragment>
))}
</div>
{toolbarExtras ? (
<>
<div className="mx-1 h-5 w-px bg-zinc-200 dark:bg-zinc-700/70" />
<div className="flex items-center gap-1">{toolbarExtras}</div>
</>
) : null}
</div>
) : null}
{/* Editor */}
<div
className={cn("relative", editorStyles)}
style={{ minHeight: editorMinHeight }}
onKeyDownCapture={(e) => {
// Cmd+K / Ctrl+K → open link popover
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
e.stopPropagation()
openLinkPopover()
return
}
if (!showMentions || filteredMentions.length === 0) return
if (e.key === "ArrowDown") {
e.preventDefault()
e.stopPropagation()
setSelectedIdx((i) =>
i < filteredMentions.length - 1 ? i + 1 : 0,
)
} else if (e.key === "ArrowUp") {
e.preventDefault()
e.stopPropagation()
setSelectedIdx((i) =>
i > 0 ? i - 1 : filteredMentions.length - 1,
)
} else if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault()
e.stopPropagation()
const user = filteredMentions[selectedIdx]
if (user) insertMention(user)
} else if (e.key === "Escape") {
e.preventDefault()
e.stopPropagation()
setShowMentions(false)
}
}}
>
<EditorContent editor={editor} />
{/* Mention suggestions */}
{showMentions && filteredMentions.length > 0 ? (
<div
ref={mentionPopupRef}
className={cn(
"absolute bottom-3 left-3 z-20 w-80 max-w-[calc(100%-1.5rem)] overflow-hidden rounded-xl border border-zinc-200 bg-white shadow-xl ring-1 ring-black/5",
"animate-in fade-in-0 slide-in-from-bottom-1 duration-150",
"dark:border-zinc-800 dark:bg-zinc-950 dark:ring-white/5",
)}
>
{/* Header */}
<div className="flex items-center gap-2 border-b border-zinc-100 bg-zinc-50/60 px-3 py-2 dark:border-zinc-800/80 dark:bg-zinc-900/40">
<FontAwesomeIcon
icon={faAt}
className="size-3 text-zinc-400"
/>
<span className="text-xs font-medium text-zinc-500 dark:text-zinc-400">
{mentionQuery
? `Matching "${mentionQuery}"`
: "Mention someone or a page"}
</span>
</div>
<div className="max-h-72 overflow-y-auto py-1">
{(() => {
const peopleItems = filteredMentions
.map((m, idx) => ({ m, idx }))
.filter(({ m }) => m.kind !== "page")
const pageItems = filteredMentions
.map((m, idx) => ({ m, idx }))
.filter(({ m }) => m.kind === "page")
const renderRow = (
user: MarkdownMentionUser,
index: number,
) => {
const isSelected = index === selectedIdx
const isPage = user.kind === "page"
const initial = (user.displayName || "?")
.trim()
.charAt(0)
.toUpperCase()
return (
<button
key={user.id}
type="button"
className={cn(
"group/row flex w-full cursor-pointer items-center gap-3 px-2.5 py-2 text-left transition-colors",
"rounded-lg",
isSelected
? "bg-zinc-100 dark:bg-zinc-900"
: "hover:bg-zinc-50 dark:hover:bg-zinc-900/60",
)}
onMouseDown={(e) => e.preventDefault()}
onMouseEnter={() => setSelectedIdx(index)}
onClick={() => insertMention(user)}
>
{/* Avatar */}
<span
className={cn(
"relative flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-full text-xs font-semibold",
isPage
? "bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900"
: "bg-gradient-to-br from-zinc-100 to-zinc-200 text-zinc-700 dark:from-zinc-800 dark:to-zinc-900 dark:text-zinc-200",
)}
>
{user.avatarUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={user.avatarUrl}
alt=""
className="size-full object-cover"
/>
) : (
<FontAwesomeIcon
icon={isPage ? faBuilding : faUser}
className="size-3 opacity-80"
/>
)}
{!user.avatarUrl && initial ? (
<span className="absolute">{initial}</span>
) : null}
</span>
{/* Name + subtitle */}
<span className="flex min-w-0 flex-1 flex-col leading-tight">
<span className="truncate text-sm font-medium text-zinc-900 dark:text-zinc-50">
{user.displayName}
</span>
<span className="truncate text-xs text-zinc-500 dark:text-zinc-400">
{user.subtitle ?? `@${mentionTextFor(user)}`}
</span>
</span>
{/* Keyboard hint on selected row */}
{isSelected ? (
<kbd className="ml-2 hidden shrink-0 rounded-md border border-zinc-200 bg-white px-1.5 py-0.5 font-mono text-[10px] font-medium text-zinc-500 shadow-sm sm:inline-flex dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-400">
↵
</kbd>
) : null}
</button>
)
}
return (
<>
{peopleItems.length > 0 && (
<div className="px-1">
<div className="px-2 pb-1 pt-1.5 text-[10px] font-semibold uppercase tracking-wider text-zinc-400 dark:text-zinc-500">
People
</div>
{peopleItems.map(({ m, idx }) => renderRow(m, idx))}
</div>
)}
{pageItems.length > 0 && (
<div className="px-1">
{peopleItems.length > 0 && (
<div className="my-1 h-px bg-zinc-100 dark:bg-zinc-800/80" />
)}
<div className="px-2 pb-1 pt-1.5 text-[10px] font-semibold uppercase tracking-wider text-zinc-400 dark:text-zinc-500">
Pages
</div>
{pageItems.map(({ m, idx }) => renderRow(m, idx))}
</div>
)}
</>
)
})()}
</div>
{/* Footer hint */}
<div className="flex items-center justify-between gap-3 border-t border-zinc-100 bg-zinc-50/60 px-3 py-1.5 text-[10px] text-zinc-400 dark:border-zinc-800/80 dark:bg-zinc-900/40 dark:text-zinc-500">
<span className="flex items-center gap-1.5">
<kbd className="rounded border border-zinc-200 bg-white px-1 font-mono text-[10px] dark:border-zinc-700 dark:bg-zinc-900">
↑↓
</kbd>
navigate
</span>
<span className="flex items-center gap-1.5">
<kbd className="rounded border border-zinc-200 bg-white px-1 font-mono text-[10px] dark:border-zinc-700 dark:bg-zinc-900">
esc
</kbd>
close
</span>
</div>
</div>
) : null}
{/* Link popover (Cmd+K) */}
{showLinkPopover ? (
<div className="absolute left-3 right-3 top-3 z-20 flex items-center gap-2 rounded-lg border border-zinc-200 bg-white p-2 shadow-lg dark:border-zinc-700 dark:bg-zinc-900">
<FontAwesomeIcon icon={faLink} className="size-3.5 shrink-0 text-zinc-400" />
<input
ref={linkInputRef}
type="url"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
applyLink()
} else if (e.key === "Escape") {
e.preventDefault()
setShowLinkPopover(false)
editor?.commands.focus()
}
}}
placeholder="https://..."
className="min-w-0 flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground"
/>
{editor?.isActive("link") ? (
<button
type="button"
onClick={removeLink}
className="flex size-7 shrink-0 items-center justify-center rounded-md text-zinc-400 transition-colors hover:bg-zinc-100 hover:text-red-500 dark:hover:bg-zinc-800 dark:hover:text-red-400"
title="Remove link"
>
<FontAwesomeIcon icon={faLinkSlash} className="size-3.5" />
</button>
) : null}
<button
type="button"
onClick={applyLink}
className="shrink-0 rounded-md bg-zinc-900 px-3 py-1 text-xs font-medium text-white transition-colors hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
>
Apply
</button>
</div>
) : null}
</div>
{/* Bottom bar: actions + toolbar extras when toolbar is hidden */}
{(actions || (!showToolbar && toolbarExtras)) ? (
<div className="flex items-center justify-between px-3 py-1.5">
<div className="flex items-center gap-1">
{!showToolbar ? toolbarExtras : null}
</div>
{actions}
</div>
) : null}
</div>
)
},
)
export { MarkdownEditor }
export type { MarkdownEditorProps }