feat(markdown-editor): shadcn toolbar with link/image popovers and table controls

Rework the editor toolbar into a shadcn-styled family of small modules:
heading and list dropdowns, an adaptive table menu (GFM-safe: no merge/split),
inline popovers for links and images with inline validation + URL normalization
(replacing window.prompt), click-to-edit link/image handles anchored to the
node, a reset-formatting action, roving-tabindex a11y, and tooltips. URL/image
sources are normalized to absolute https and gated by a protocol allowlist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-07-07 05:27:26 +07:00
co-authored by Claude Opus 4.8
parent 30b22f9c17
commit 36608a9dd0
19 changed files with 2045 additions and 340 deletions
@@ -1,6 +1,8 @@
// Deliberately does NOT re-export the heavy MarkdownEditor value — that would statically pull the tiptap chunk
// into any route importing a light util from here. Consume the mode-switching MarkdownEditorField instead (it
// owns the lazy() boundary, so importing it is chunk-free until rich mode renders).
// For the raw rich editor standalone, import './markdown-editor' by path — that eagerly bundles tiptap into the
// route, so wrap it in lazy(() => import('./markdown-editor')) yourself if you need the chunk deferred.
export { MarkdownEditorField } from './markdown-editor-field';
export type { MarkdownEditorFieldHandle } from './markdown-editor-field';
export { findVariableUseRanges, VARIABLE_RE, variableUseRegex } from './markdown-editor-variable-syntax';
@@ -98,10 +98,12 @@ const TunedStarterKit = StarterKit.extend({
// • link autolink/linkOnPaste: true — a bare URL/email becomes a link on load, paste, AND typing, kept
// symmetric with the marked layer (which no longer neutralises autolink/url). Do NOT set false: it
// diverges typing from load and re-freezes bare URLs as text.
// • link openOnClick: false — a click seats the caret in the link instead of navigating away, so LinkHandle
// (markdown-editor-link-handle.tsx) can show the edit popover; opening still works via that popover's button.
export const createMarkdownExtensions = (placeholder?: string) => [
TunedStarterKit.configure({
codeBlock: { HTMLAttributes: { class: 'hljs' } },
link: { autolink: true, linkOnPaste: true },
link: { autolink: true, linkOnPaste: true, openOnClick: false },
underline: false,
}),
TunedTable.configure({ resizable: true }),
@@ -0,0 +1,139 @@
import type { Editor } from '@tiptap/react';
import { Check, Trash2 } from 'lucide-react';
import { useId, useState } from 'react';
import { Input } from '@/components/ui/input';
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '@/components/ui/input-group';
import { Label } from '@/components/ui/label';
import { normalizeImageSrc } from './markdown-editor-toolbar-url';
interface ImageEditFormProps {
// Focus the URL input on mount. True when the user explicitly opened the form (toolbar button); false for the
// on-image popover, which appears when an image is selected and must not steal focus.
autoFocus?: boolean;
editor: Editor;
initialAlt: string;
initialSrc: string;
// true = editing the selected image (updateAttributes + Remove); false = inserting a new one (setImage).
isEditing: boolean;
onDone: () => void;
}
// Shared body of the image editor — the same src field + validation + alt used by the toolbar Insert-image popover
// AND the on-image popover (markdown-editor-image-handle.tsx). Seeds its own state from the initial props on mount,
// so consumers give it a fresh `key` per editing session.
export function ImageEditForm({
autoFocus = true,
editor,
initialAlt,
initialSrc,
isEditing,
onDone,
}: ImageEditFormProps) {
const [src, setSrc] = useState(initialSrc);
const [alt, setAlt] = useState(initialAlt);
const srcId = useId();
const altId = useId();
const errorId = useId();
const normalizedSrc = normalizeImageSrc(src);
const isInvalid = src !== '' && normalizedSrc === null;
const apply = () => {
if (!normalizedSrc) {
return;
}
if (isEditing) {
editor
.chain()
.focus()
.updateAttributes('image', { alt: alt || null, src: normalizedSrc })
.run();
} else {
editor
.chain()
.focus()
.setImage({ alt: alt || undefined, src: normalizedSrc })
.run();
}
onDone();
};
const remove = () => {
editor.chain().focus().deleteSelection().run();
onDone();
};
const applyOnEnter = (event: React.KeyboardEvent) => {
if (event.key === 'Enter') {
event.preventDefault();
apply();
}
};
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor={srcId}>Image URL</Label>
<InputGroup>
<InputGroupInput
aria-describedby={isInvalid ? errorId : undefined}
aria-invalid={isInvalid}
autoFocus={autoFocus}
id={srcId}
onChange={(event) => setSrc(event.target.value)}
onKeyDown={applyOnEnter}
placeholder="https://example.com/image.png"
type="url"
value={src}
/>
<InputGroupAddon
align="inline-end"
className="gap-0"
>
<InputGroupButton
aria-label={isEditing ? 'Apply image' : 'Insert image'}
disabled={src === '' || isInvalid}
onClick={apply}
size="icon-xs"
>
<Check />
</InputGroupButton>
{isEditing ? (
<InputGroupButton
aria-label="Remove image"
onClick={remove}
size="icon-xs"
>
<Trash2 />
</InputGroupButton>
) : null}
</InputGroupAddon>
</InputGroup>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor={altId}>Alt text (optional)</Label>
<Input
id={altId}
onChange={(event) => setAlt(event.target.value)}
onKeyDown={applyOnEnter}
placeholder="Describe the image"
value={alt}
/>
</div>
{isInvalid ? (
<p
className="text-destructive text-xs"
id={errorId}
role="alert"
>
Only http(s) or base64 raster image URLs are allowed.
</p>
) : null}
</div>
);
}
@@ -0,0 +1,113 @@
import type { Editor } from '@tiptap/react';
import { posToDOMRect } from '@tiptap/core';
import { NodeSelection } from '@tiptap/pm/state';
import { useEffect, useState } from 'react';
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover';
import { ImageEditForm } from './markdown-editor-image-edit-form';
interface ImageTarget {
alt: string;
key: number;
rect: { height: number; left: number; top: number; width: number };
src: string;
}
// Shows the shared image editor anchored to the selected image. Clicking an image makes it a NodeSelection (it
// never navigates), so `selection instanceof NodeSelection && node.type.name === 'image'` detects "an image is
// selected". Overlay-only — it never mutates the doc until a form action runs (like TableHandles / LinkHandle).
export function ImageHandle({ editor }: { editor: Editor }) {
const { close, target } = useImageHandle(editor);
if (!target) {
return null;
}
const { alt, key, rect, src } = target;
return (
<Popover
onOpenChange={(next) => {
if (!next) {
close();
}
}}
open
>
<PopoverAnchor asChild>
<span
aria-hidden
className="pointer-events-none fixed"
style={{ height: rect.height, left: rect.left, top: rect.top, width: rect.width }}
/>
</PopoverAnchor>
<PopoverContent
align="start"
className="w-80"
data-image-handle=""
onCloseAutoFocus={(event) => {
event.preventDefault();
editor.commands.focus();
}}
onOpenAutoFocus={(event) => {
// The popover appears when an image is selected — keep focus in the doc so it doesn't steal it;
// the user clicks into the URL field only to actually edit.
event.preventDefault();
}}
side="bottom"
sideOffset={6}
>
<ImageEditForm
autoFocus={false}
editor={editor}
initialAlt={alt}
initialSrc={src}
isEditing
key={key}
onDone={close}
/>
</PopoverContent>
</Popover>
);
}
function useImageHandle(editor: Editor) {
const [target, setTarget] = useState<ImageTarget | null>(null);
useEffect(() => {
const update = () => {
const { selection } = editor.state;
if (!(selection instanceof NodeSelection) || selection.node.type.name !== 'image') {
setTarget(null);
return;
}
const rect = posToDOMRect(editor.view, selection.from, selection.to);
const { alt, src } = selection.node.attrs;
setTarget({ alt: (alt as string) ?? '', key: selection.from, rect, src: (src as string) ?? '' });
};
const clear = () => setTarget(null);
editor.on('selectionUpdate', update);
// Fixed-positioned anchor goes stale on scroll/resize — drop it (it reappears on the next selection).
const scrollParent = editor.view.dom.closest('.tiptap-content') ?? window;
scrollParent.addEventListener('scroll', clear, { passive: true });
window.addEventListener('resize', clear);
return () => {
editor.off('selectionUpdate', update);
scrollParent.removeEventListener('scroll', clear);
window.removeEventListener('resize', clear);
};
}, [editor]);
return { close: () => setTarget(null), target };
}
@@ -1,20 +1,21 @@
import { describe, expect, it } from 'vitest';
import { isSafeImageSrc, isSafeUrl } from './markdown-editor-toolbar';
import { normalizeImageSrc, normalizeLinkUrl } from './markdown-editor-toolbar-url';
// The Image extension stores whatever src it is handed; isSafeImageSrc is the allowlist that blocks dangerous
// PROTOCOLS (javascript:, data:text/html, vbscript:, file:) before a src is saved. A bare relative string
// resolves against the page origin (http/https) and is intentionally allowed — the guard validates the
// protocol, not that the URL is absolute. data: URLs are limited to base64 raster formats: svg+xml is the one
// image type that can carry script.
describe('isSafeImageSrc — image-src protocol allowlist', () => {
// normalizeImageSrc makes a scheme-less src absolute (https://) and validates the protocol before it is saved:
// http(s) and base64 raster data: URLs pass through; data:image/svg+xml is rejected (SVG can carry script), as
// are data:text/html, application/*, javascript:, vbscript:, and malformed input.
describe('normalizeImageSrc — prepend https to scheme-less src, validate protocol', () => {
it.each([
'http://example.com/a.png',
'https://example.com/a.png',
'data:image/png;base64,AAAA',
'data:image/webp;base64,AAAA',
])('allows %s', (url) => {
expect(isSafeImageSrc(url)).toBe(true);
['example.com/a.png', 'https://example.com/a.png'],
['//cdn.example.com/a.png', 'https://cdn.example.com/a.png'],
[' example.com/a.png ', 'https://example.com/a.png'],
['http://example.com/a.png', 'http://example.com/a.png'],
['https://example.com/a.png?w=1', 'https://example.com/a.png?w=1'],
['data:image/png;base64,AAAA', 'data:image/png;base64,AAAA'],
['data:image/webp;base64,AAAA', 'data:image/webp;base64,AAAA'],
])('normalizes %s → %s', (input, expected) => {
expect(normalizeImageSrc(input)).toBe(expected);
});
it.each([
@@ -24,33 +25,42 @@ describe('isSafeImageSrc — image-src protocol allowlist', () => {
'data:image/svg+xml;utf8,<svg onload=alert(1)/>',
'data:image/svg+xml;base64,AAAA',
'vbscript:msgbox(1)',
'file:///etc/passwd',
'http://', // malformed → URL constructor throws → rejected via the catch
])('rejects %s', (url) => {
expect(isSafeImageSrc(url)).toBe(false);
'http://', // malformed → no host
'',
])('rejects %s', (input) => {
expect(normalizeImageSrc(input)).toBeNull();
});
});
describe('isSafeUrl — link-href protocol allowlist', () => {
// A manually-typed link must be made absolute — a scheme-less input like "example.com" would otherwise persist as
// a relative href the browser resolves against the current origin. normalizeLinkUrl prepends https:// unless the
// input already carries an allowed scheme, validates the protocol with no base URL, and returns null for
// unsafe/malformed input. Already-schemed values pass through verbatim (case + query chars preserved).
describe('normalizeLinkUrl — prepend https to scheme-less input, validate protocol', () => {
it.each([
'https://example.com/path?a=1|2',
'http://example.com',
'mailto:a@b.com',
'tel:+123',
'/relative/path',
'#anchor',
'./sibling',
])('allows %s', (url) => {
expect(isSafeUrl(url)).toBe(true);
['example.com', 'https://example.com'],
['www.example.com', 'https://www.example.com'],
['example.com:8080', 'https://example.com:8080'],
['localhost:3000', 'https://localhost:3000'],
['//evil.com', 'https://evil.com'],
[' example.com ', 'https://example.com'],
['http://example.com', 'http://example.com'],
['https://example.com/path?a=1|2', 'https://example.com/path?a=1|2'],
['mailto:a@b.com', 'mailto:a@b.com'],
['tel:+123', 'tel:+123'],
])('normalizes %s → %s', (input, expected) => {
expect(normalizeLinkUrl(input)).toBe(expected);
});
it.each([
'javascript:alert(1)',
'data:text/html,<script>alert(1)</script>',
'vbscript:msgbox(1)',
'file:///etc/passwd',
'http://', // malformed → rejected via the catch
])('rejects %s', (url) => {
expect(isSafeUrl(url)).toBe(false);
'#anchor', // no host once prepended → rejected
'https://', // malformed → no host
'',
' ',
])('rejects %s', (input) => {
expect(normalizeLinkUrl(input)).toBeNull();
});
});
@@ -0,0 +1,122 @@
import type { Editor } from '@tiptap/react';
import { ArrowUpRight, Check, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '@/components/ui/input-group';
import { normalizeLinkUrl } from './markdown-editor-toolbar-url';
interface LinkEditFormProps {
// Focus the URL input on mount. True when the user explicitly opened the form (toolbar button); false for the
// on-link popover, which appears whenever the caret enters a link and must not steal focus from the doc.
autoFocus?: boolean;
editor: Editor;
initialUrl: string;
isActive: boolean;
onDone: () => void;
}
// Shared body of the link editor — the same URL field + validation + Apply/Open/Remove used by the toolbar Link
// popover AND the on-link popover (markdown-editor-link-handle.tsx). Seeds its own `url` from `initialUrl` on
// mount, so consumers give it a fresh `key` per editing session.
export function LinkEditForm({ autoFocus = true, editor, initialUrl, isActive, onDone }: LinkEditFormProps) {
const [url, setUrl] = useState(initialUrl);
// Normalized absolute href (scheme prepended, protocol validated) or null when the input is unsafe/empty.
const href = normalizeLinkUrl(url);
const isInvalid = url !== '' && href === null;
const applyLink = () => {
if (!href) {
return;
}
const { empty } = editor.state.selection;
if (empty && !isActive) {
// No selection to wrap → insert the URL as its own linked text (matches Docs/Notion). Shows what the
// user typed but links to the normalized href.
editor
.chain()
.focus()
.insertContent({ marks: [{ attrs: { href }, type: 'link' }], text: url.trim(), type: 'text' })
.run();
} else {
editor.chain().focus().extendMarkRange('link').setLink({ href }).run();
}
onDone();
};
const removeLink = () => {
editor.chain().focus().extendMarkRange('link').unsetLink().run();
onDone();
};
const openInNewTab = () => {
if (href) {
window.open(href, '_blank', 'noopener,noreferrer');
}
};
return (
<div className="flex flex-col gap-2">
<InputGroup>
<InputGroupInput
aria-invalid={isInvalid}
aria-label="Link URL"
autoFocus={autoFocus}
onChange={(event) => setUrl(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
applyLink();
}
}}
placeholder="https://example.com"
type="url"
value={url}
/>
<InputGroupAddon
align="inline-end"
className="gap-0"
>
<InputGroupButton
aria-label="Apply link"
disabled={url === '' || isInvalid}
onClick={applyLink}
size="icon-xs"
>
<Check />
</InputGroupButton>
<InputGroupButton
aria-label="Open link in new tab"
disabled={url === '' || isInvalid}
onClick={openInNewTab}
size="icon-xs"
>
<ArrowUpRight />
</InputGroupButton>
{isActive ? (
<InputGroupButton
aria-label="Remove link"
onClick={removeLink}
size="icon-xs"
>
<Trash2 />
</InputGroupButton>
) : null}
</InputGroupAddon>
</InputGroup>
{isInvalid ? (
<p
className="text-destructive text-xs"
role="alert"
>
Only http, https, mailto and tel links are allowed.
</p>
) : null}
</div>
);
}
@@ -0,0 +1,121 @@
import type { Editor } from '@tiptap/react';
import { getMarkRange, posToDOMRect } from '@tiptap/core';
import { useEffect, useState } from 'react';
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover';
import { LinkEditForm } from './markdown-editor-link-edit-form';
interface LinkTarget {
href: string;
key: string;
rect: { height: number; left: number; top: number; width: number };
}
// Shows the shared link editor anchored to the link under the caret. openOnClick:false (markdown-editor-extensions)
// seats the caret in a clicked link instead of navigating, so `selection.empty && isActive('link')` detects "the
// caret is on a link" for both mouse and keyboard. Overlay-only — it never mutates the doc until a form action
// runs, so it stays out of the byte round-trip (like TableHandles).
export function LinkHandle({ editor }: { editor: Editor }) {
const { close, target } = useLinkHandle(editor);
if (!target) {
return null;
}
const { href, key, rect } = target;
return (
<Popover
onOpenChange={(next) => {
if (!next) {
close();
}
}}
open
>
<PopoverAnchor asChild>
<span
aria-hidden
className="pointer-events-none fixed"
style={{ height: rect.height, left: rect.left, top: rect.top, width: rect.width }}
/>
</PopoverAnchor>
<PopoverContent
align="start"
className="w-80 p-2"
data-link-handle=""
onCloseAutoFocus={(event) => {
event.preventDefault();
editor.commands.focus();
}}
onOpenAutoFocus={(event) => {
// The popover appears whenever the caret enters a link, so keep focus in the doc — stealing it
// would interrupt typing/navigation. The user clicks into the URL field only to actually edit.
event.preventDefault();
}}
side="bottom"
sideOffset={6}
>
<LinkEditForm
autoFocus={false}
editor={editor}
initialUrl={href}
isActive
key={key}
onDone={close}
/>
</PopoverContent>
</Popover>
);
}
function useLinkHandle(editor: Editor) {
const [target, setTarget] = useState<LinkTarget | null>(null);
useEffect(() => {
const linkType = editor.schema.marks.link;
const update = () => {
const { selection } = editor.state;
if (!linkType || !selection.empty || !editor.isActive('link')) {
setTarget(null);
return;
}
const range = getMarkRange(selection.$from, linkType);
if (!range) {
setTarget(null);
return;
}
const rect = posToDOMRect(editor.view, range.from, range.to);
const href = (editor.getAttributes('link').href as string | undefined) ?? '';
setTarget({ href, key: `${range.from}-${range.to}`, rect });
};
const clear = () => setTarget(null);
editor.on('selectionUpdate', update);
// Fixed-positioned anchor goes stale on scroll/resize — drop it (it reappears on the next caret entry).
const scrollParent = editor.view.dom.closest('.tiptap-content') ?? window;
scrollParent.addEventListener('scroll', clear, { passive: true });
window.addEventListener('resize', clear);
return () => {
editor.off('selectionUpdate', update);
scrollParent.removeEventListener('scroll', clear);
window.removeEventListener('resize', clear);
};
}, [editor]);
return { close: () => setTarget(null), target };
}
@@ -0,0 +1,102 @@
import type { Editor } from '@tiptap/react';
import type { LucideIcon } from 'lucide-react';
import { AlignCenter, AlignLeft, AlignRight } from 'lucide-react';
export type ColumnAlign = 'center' | 'left' | 'right';
export const ALIGN_OPTIONS: { icon: LucideIcon; label: string; value: ColumnAlign }[] = [
{ icon: AlignLeft, label: 'Left', value: 'left' },
{ icon: AlignCenter, label: 'Center', value: 'center' },
{ icon: AlignRight, label: 'Right', value: 'right' },
];
// Empties every cell in the caret's row or column (Notion's "Clear contents"), leaving the structure intact.
export function clearLineContents(editor: Editor, axis: 'column' | 'row'): void {
editor
.chain()
.focus()
.command(({ dispatch, editor: instance, tr }) => {
const paragraphType = instance.schema.nodes.paragraph;
if (dispatch && paragraphType) {
// Descending order so each replacement leaves earlier cell positions valid.
for (const pos of cellPositionsInLine(editor, axis).reverse()) {
const cell = tr.doc.nodeAt(pos);
if (cell) {
tr.replaceWith(pos + 1, pos + cell.nodeSize - 1, paragraphType.create());
}
}
}
return true;
})
.run();
}
// Whether a table uses a header row (its first row holds `tableHeader` cells). Defaults to the table around the
// caret; pass `pos` to read a specific cell's table instead — the hover grips open without moving the selection,
// so they must resolve the hovered table by position rather than the caret's last table.
export function hasHeaderRow(editor: Editor, pos?: number): boolean {
const $pos = pos == null ? editor.state.selection.$from : editor.state.doc.resolve(pos);
for (let depth = $pos.depth; depth > 0; depth--) {
if ($pos.node(depth).type.name === 'table') {
return $pos.node(depth).firstChild?.firstChild?.type.name === 'tableHeader';
}
}
return false;
}
// GFM aligns whole COLUMNS (the delimiter row), stored by @tiptap/extension-table as an `align` attr on every
// cell — setCellAttribute only touches the caret cell, so set it on the whole column or only one cell aligns
// until the doc reloads.
export function setColumnAlign(editor: Editor, align: ColumnAlign): void {
editor
.chain()
.focus()
.command(({ dispatch, tr }) => {
if (dispatch) {
for (const pos of cellPositionsInLine(editor, 'column')) {
tr.setNodeAttribute(pos, 'align', align);
}
}
return true;
})
.run();
}
// Walks the table containing the caret and yields the doc position of every cell in the caret's column (or row).
// colspan is always 1 here — GFM tables have no merged cells — so a cell's index within its row IS its column.
function cellPositionsInLine(editor: Editor, axis: 'column' | 'row'): number[] {
const { $from } = editor.state.selection;
let depth = $from.depth;
while (depth > 0 && !['tableCell', 'tableHeader'].includes($from.node(depth).type.name)) {
depth--;
}
if (depth === 0) {
return [];
}
const targetIndex = axis === 'column' ? $from.index(depth - 1) : $from.index(depth - 2);
const tableDepth = depth - 2;
const tablePos = $from.before(tableDepth);
const positions: number[] = [];
$from.node(tableDepth).forEach((row, rowOffset, rowIndex) => {
row.forEach((cell, cellOffset, cellIndex) => {
const matches = axis === 'column' ? cellIndex === targetIndex : rowIndex === targetIndex;
if (matches) {
positions.push(tablePos + 1 + rowOffset + 1 + cellOffset);
}
});
});
return positions;
}
@@ -0,0 +1,370 @@
import type { Editor } from '@tiptap/react';
import { useEditorState } from '@tiptap/react';
import {
AlignLeft,
ArrowDown,
ArrowLeft,
ArrowRight,
ArrowUp,
Delete,
Eraser,
GripHorizontal,
GripVertical,
PanelTop,
} from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Switch } from '@/components/ui/switch';
import { ALIGN_OPTIONS, clearLineContents, hasHeaderRow, setColumnAlign } from './markdown-editor-table-commands';
interface HoverTarget {
cellPos: number;
colLeft: number;
colWidth: number;
rowHeight: number;
rowTop: number;
tableLeft: number;
tableTop: number;
}
type OpenMenu = 'column' | 'row' | null;
const GRIP = 18;
const GRIP_CLASS =
'bg-muted hover:bg-accent text-muted-foreground hover:text-accent-foreground flex items-center justify-center rounded border shadow-sm transition-colors';
interface TableHandlesController {
onMenuChange: (menu: 'column' | 'row') => (isOpen: boolean) => void;
open: OpenMenu;
target: HoverTarget | null;
}
// Notion-style hover handles: a grip appears above the hovered column and left of the hovered row; clicking it
// opens a shadcn menu of markdown-safe row/column operations. Overlay-only — it never mutates the document until
// a menu item runs — so it stays out of the byte round-trip. Merged cells / colour / header-column are omitted
// (not GFM-representable); the toolbar Table dropdown carries the same ops for keyboard/no-hover users.
export function TableHandles({ editor }: { editor: Editor }) {
const { onMenuChange, open, target } = useTableHandles(editor);
if (!target) {
return null;
}
const focusTarget = () => editor.chain().focus().setTextSelection(target.cellPos);
return createPortal(
<>
<DropdownMenu
onOpenChange={onMenuChange('column')}
open={open === 'column'}
>
<DropdownMenuTrigger asChild>
<button
aria-label="Column actions"
className={GRIP_CLASS}
data-table-grip=""
style={{
height: GRIP,
left: target.colLeft + target.colWidth / 2 - GRIP / 2,
position: 'fixed',
top: target.tableTop - GRIP / 2,
width: GRIP,
zIndex: 40,
}}
type="button"
>
<GripHorizontal className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="min-w-[176px]"
onCloseAutoFocus={(event) => {
event.preventDefault();
editor.commands.focus();
}}
>
<DropdownMenuItem onSelect={() => focusTarget().addColumnBefore().run()}>
<ArrowLeft className="text-muted-foreground size-4 shrink-0" />
Insert left
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => focusTarget().addColumnAfter().run()}>
<ArrowRight className="text-muted-foreground size-4 shrink-0" />
Insert right
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<AlignLeft className="text-muted-foreground size-4 shrink-0" />
Align column
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{ALIGN_OPTIONS.map((option) => (
<DropdownMenuItem
key={option.value}
onSelect={() => {
focusTarget().run();
setColumnAlign(editor, option.value);
}}
>
<option.icon className="text-muted-foreground size-4 shrink-0" />
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem
onSelect={() => {
focusTarget().run();
clearLineContents(editor, 'column');
}}
>
<Eraser className="text-muted-foreground size-4 shrink-0" />
Clear contents
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => focusTarget().deleteColumn().run()}>
<Delete className="text-muted-foreground size-4 shrink-0" />
Delete column
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu
onOpenChange={onMenuChange('row')}
open={open === 'row'}
>
<DropdownMenuTrigger asChild>
<button
aria-label="Row actions"
className={GRIP_CLASS}
data-table-grip=""
style={{
height: GRIP,
left: target.tableLeft - GRIP / 2,
position: 'fixed',
top: target.rowTop + target.rowHeight / 2 - GRIP / 2,
width: GRIP,
zIndex: 40,
}}
type="button"
>
<GripVertical className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="min-w-[176px]"
onCloseAutoFocus={(event) => {
event.preventDefault();
editor.commands.focus();
}}
>
<RowHeaderToggleItem
cellPos={target.cellPos}
editor={editor}
/>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => focusTarget().addRowBefore().run()}>
<ArrowUp className="text-muted-foreground size-4 shrink-0" />
Insert above
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => focusTarget().addRowAfter().run()}>
<ArrowDown className="text-muted-foreground size-4 shrink-0" />
Insert below
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
focusTarget().run();
clearLineContents(editor, 'row');
}}
>
<Eraser className="text-muted-foreground size-4 shrink-0" />
Clear contents
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => focusTarget().deleteRow().run()}>
<Delete className="text-muted-foreground size-4 shrink-0 -rotate-90" />
Delete row
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>,
document.body,
);
}
// Its useEditorState subscription must stay inside the row-grip DropdownMenuContent: Radix Presence unmounts it
// on close, so the deliberately non-reactive useTableHandles hover path pays no per-transaction cost while idle.
// Reads header state by the hovered cell's position, not the caret — the grip opens without moving the selection.
function RowHeaderToggleItem({ cellPos, editor }: { cellPos: number; editor: Editor }) {
const isHeaderRow = useEditorState({
editor,
selector: ({ editor }) => hasHeaderRow(editor, cellPos),
});
return (
<DropdownMenuItem
aria-checked={isHeaderRow}
onSelect={(event) => {
// preventDefault keeps the menu open so the switch flips in place; setTextSelection (not .focus())
// seats the toggle in the hovered table without pulling DOM focus off the open menu.
event.preventDefault();
editor.chain().setTextSelection(cellPos).toggleHeaderRow().run();
}}
role="menuitemcheckbox"
>
<PanelTop className="text-muted-foreground size-4 shrink-0" />
<span>Header row</span>
<Switch
checked={isHeaderRow}
className="pointer-events-none ml-auto"
tabIndex={-1}
/>
</DropdownMenuItem>
);
}
// Headless controller: owns every imperative DOM touch — hover tracking over ProseMirror's (non-React) cells,
// layout measurement, and the scroll/menu-freeze bookkeeping — and hands the view a plain declarative state.
// Swapping the hover strategy (e.g. to a ProseMirror decoration plugin) means rewriting only this hook.
function useTableHandles(editor: Editor): TableHandlesController {
const [target, setTarget] = useState<HoverTarget | null>(null);
const [open, setOpen] = useState<OpenMenu>(null);
const clearTimer = useRef<null | ReturnType<typeof setTimeout>>(null);
const openRef = useRef<OpenMenu>(null);
const cellPosRef = useRef<null | number>(null);
useEffect(() => {
openRef.current = open;
}, [open]);
useEffect(() => {
const dom = editor.view.dom;
const cancelClear = () => {
if (clearTimer.current) {
clearTimeout(clearTimer.current);
clearTimer.current = null;
}
};
const clearSoon = () => {
cancelClear();
clearTimer.current = setTimeout(() => {
if (!openRef.current) {
cellPosRef.current = null;
setTarget(null);
}
}, 120);
};
// One document-level listener rather than the editor's own: the grip straddles the table border, so the
// editor's mouseleave fires the instant the cursor crosses onto it and it would flicker away. Here we
// classify each move — over a grip (keep), over a cell (reposition), over neither (hide) — so travelling
// from a cell onto the grip never clears. `cellPosRef` skips re-renders while the cursor stays in one cell.
const handleMove = (event: MouseEvent) => {
if (openRef.current) {
return;
}
const el = event.target as HTMLElement | null;
if (el?.closest?.('[data-table-grip]')) {
cancelClear();
return;
}
const cell = el?.closest?.('td, th');
if (!(cell instanceof HTMLElement) || !dom.contains(cell)) {
if (cellPosRef.current !== null) {
clearSoon();
}
return;
}
cancelClear();
let cellPos: number;
try {
cellPos = editor.view.posAtDOM(cell, 0);
} catch {
return;
}
if (cellPos === cellPosRef.current) {
return;
}
const row = cell.closest('tr');
const table = cell.closest('table');
if (!row || !table) {
return;
}
cellPosRef.current = cellPos;
const cellRect = cell.getBoundingClientRect();
const rowRect = row.getBoundingClientRect();
const tableRect = table.getBoundingClientRect();
setTarget({
cellPos,
colLeft: cellRect.left,
colWidth: cellRect.width,
rowHeight: rowRect.height,
rowTop: rowRect.top,
tableLeft: tableRect.left,
tableTop: tableRect.top,
});
};
document.addEventListener('mousemove', handleMove);
// Fixed-positioned grips go stale on scroll — drop them (they reappear on the next hover).
const scrollParent = dom.closest('.tiptap-content') ?? window;
const handleScroll = () => {
if (!openRef.current) {
cellPosRef.current = null;
setTarget(null);
}
};
scrollParent.addEventListener('scroll', handleScroll, { passive: true });
return () => {
cancelClear();
document.removeEventListener('mousemove', handleMove);
scrollParent.removeEventListener('scroll', handleScroll);
};
}, [editor]);
const onMenuChange = (menu: 'column' | 'row') => (isOpen: boolean) => {
setOpen(isOpen ? menu : null);
if (!isOpen) {
cellPosRef.current = null;
setTarget(null);
}
};
return { onMenuChange, open, target };
}
@@ -0,0 +1,86 @@
import type { ReactNode } from 'react';
import { Button } from '@/components/ui/button';
import { Toggle } from '@/components/ui/toggle';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
interface ToolbarButtonProps {
children: ReactNode;
disabled?: boolean;
label: string;
onClick: () => void;
shortcut?: string;
}
interface ToolbarToggleProps {
children: ReactNode;
disabled?: boolean;
label: string;
onPressedChange: () => void;
pressed: boolean;
shortcut?: string;
}
interface ToolbarTooltipProps {
children: ReactNode;
label: string;
shortcut?: string;
}
// One-shot actions (undo, insert…). A plain button — no aria-pressed, unlike a Toggle.
export function ToolbarButton({ children, disabled, label, onClick, shortcut }: ToolbarButtonProps) {
return (
<ToolbarTooltip
label={label}
shortcut={shortcut}
>
<Button
aria-label={label}
data-toolbar-item=""
disabled={disabled}
onClick={onClick}
size="icon-sm"
tabIndex={-1}
type="button"
variant="ghost"
>
{children}
</Button>
</ToolbarTooltip>
);
}
// Marks/blocks with a genuine on/off state (bold, italic, lists…). Renders aria-pressed via Radix Toggle.
export function ToolbarToggle({ children, disabled, label, onPressedChange, pressed, shortcut }: ToolbarToggleProps) {
return (
<ToolbarTooltip
label={label}
shortcut={shortcut}
>
<Toggle
aria-label={label}
data-toolbar-item=""
disabled={disabled}
onPressedChange={onPressedChange}
pressed={pressed}
size="sm"
>
{children}
</Toggle>
</ToolbarTooltip>
);
}
function ToolbarTooltip({ children, label, shortcut }: ToolbarTooltipProps) {
return (
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent className="flex items-center gap-2">
<span>{label}</span>
{shortcut ? (
<kbd className="bg-muted text-muted-foreground rounded px-1 font-mono text-[10px]">{shortcut}</kbd>
) : null}
</TooltipContent>
</Tooltip>
);
}
@@ -0,0 +1,104 @@
import type { Editor } from '@tiptap/react';
import type { LucideIcon } from 'lucide-react';
import { Check, ChevronDown, Heading1, Heading2, Heading3, Heading4, Heading5, Heading6, Type } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
interface HeadingOption {
icon: LucideIcon;
// Optical trim for the dropdown list ONLY (icons sit next to each other there): lucide's Type glyph is drawn
// taller (16u) than the Heading glyphs (12u), so at the same 16px box it reads bigger — scale it down to match
// without shrinking the box (which would misalign labels). The trigger shows one icon alone, so it stays full size.
iconClassName?: string;
label: string;
value: 'paragraph' | HeadingLevel;
}
const OPTIONS: HeadingOption[] = [
{ icon: Heading1, label: 'Heading 1', value: 1 },
{ icon: Heading2, label: 'Heading 2', value: 2 },
{ icon: Heading3, label: 'Heading 3', value: 3 },
{ icon: Heading4, label: 'Heading 4', value: 4 },
{ icon: Heading5, label: 'Heading 5', value: 5 },
{ icon: Heading6, label: 'Heading 6', value: 6 },
{ icon: Type, iconClassName: 'scale-[0.75]', label: 'Text', value: 'paragraph' },
];
interface HeadingMenuProps {
// 0 = paragraph / any non-heading block; 1-6 = the active heading level.
activeLevel: 0 | HeadingLevel;
disabled?: boolean;
editor: Editor;
}
export function HeadingMenu({ activeLevel, disabled, editor }: HeadingMenuProps) {
const isSelected = (value: HeadingOption['value']) =>
value === 'paragraph' ? activeLevel === 0 : value === activeLevel;
const active = OPTIONS.find((option) => isSelected(option.value)) ?? OPTIONS[0];
const ActiveIcon = active?.icon ?? Type;
const applyOption = (value: HeadingOption['value']) => {
if (value === 'paragraph') {
editor.chain().focus().setParagraph().run();
return;
}
editor.chain().focus().toggleHeading({ level: value }).run();
};
return (
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
aria-label={`Text style: ${active?.label ?? 'Text'}`}
className="gap-0.5 px-1.5"
data-toolbar-item=""
disabled={disabled}
size="sm"
type="button"
variant="ghost"
>
<ActiveIcon />
<ChevronDown className="size-4 opacity-50" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>Text style</TooltipContent>
</Tooltip>
<DropdownMenuContent
align="start"
className="min-w-[140px]"
onCloseAutoFocus={(event) => {
// Return focus to the editor caret (not the trigger button) so the user keeps typing.
event.preventDefault();
editor.commands.focus();
}}
>
{OPTIONS.map((option) => (
<DropdownMenuItem
key={option.value}
onSelect={() => applyOption(option.value)}
>
<option.icon className={cn('text-muted-foreground size-4 shrink-0', option.iconClassName)} />
<span>{option.label}</span>
{isSelected(option.value) ? <Check className="ml-auto size-4 shrink-0" /> : null}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,60 @@
import type { Editor } from '@tiptap/react';
import { ImagePlus } from 'lucide-react';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { ImageEditForm } from './markdown-editor-image-edit-form';
interface ImagePopoverProps {
disabled?: boolean;
editor: Editor;
}
export function ImagePopover({ disabled, editor }: ImagePopoverProps) {
const [open, setOpen] = useState(false);
return (
<Popover
onOpenChange={setOpen}
open={open}
>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
aria-label="Insert image"
data-toolbar-item=""
disabled={disabled}
size="icon-sm"
type="button"
variant="ghost"
>
<ImagePlus />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>Insert image</TooltipContent>
</Tooltip>
<PopoverContent
align="start"
className="w-80"
onCloseAutoFocus={(event) => {
event.preventDefault();
editor.commands.focus();
}}
>
<ImageEditForm
editor={editor}
initialAlt=""
initialSrc=""
isEditing={false}
onDone={() => setOpen(false)}
/>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,60 @@
import type { Editor } from '@tiptap/react';
import { Link as LinkIcon } from 'lucide-react';
import { useState } from 'react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Toggle } from '@/components/ui/toggle';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { LinkEditForm } from './markdown-editor-link-edit-form';
interface LinkPopoverProps {
disabled?: boolean;
editor: Editor;
isActive: boolean;
}
export function LinkPopover({ disabled, editor, isActive }: LinkPopoverProps) {
const [open, setOpen] = useState(false);
return (
<Popover
onOpenChange={setOpen}
open={open}
>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Toggle
aria-label="Link"
data-toolbar-item=""
disabled={disabled}
pressed={isActive}
size="sm"
>
<LinkIcon />
</Toggle>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>Link</TooltipContent>
</Tooltip>
<PopoverContent
align="start"
className="w-80 p-2"
onCloseAutoFocus={(event) => {
// Return focus to the editor caret (not the trigger) so the user keeps typing after apply/cancel.
event.preventDefault();
editor.commands.focus();
}}
>
<LinkEditForm
editor={editor}
initialUrl={(editor.getAttributes('link').href as string | undefined) ?? ''}
isActive={isActive}
onDone={() => setOpen(false)}
/>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,96 @@
import type { Editor } from '@tiptap/react';
import type { LucideIcon } from 'lucide-react';
import { Check, ChevronDown, List, ListOrdered, ListTodo } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
export type ListType = 'bullet' | 'ordered' | 'task';
interface ListOption {
icon: LucideIcon;
label: string;
value: ListType;
}
const OPTIONS: ListOption[] = [
{ icon: List, label: 'Bullet list', value: 'bullet' },
{ icon: ListOrdered, label: 'Ordered list', value: 'ordered' },
{ icon: ListTodo, label: 'Task list', value: 'task' },
];
interface ListMenuProps {
activeType: ListType | null;
disabled?: boolean;
editor: Editor;
}
export function ListMenu({ activeType, disabled, editor }: ListMenuProps) {
const active = OPTIONS.find((option) => option.value === activeType);
// The bullet-list icon doubles as the resting affordance, so the active background — not the glyph — is what
// distinguishes "in a bullet list" from "no list".
const TriggerIcon = active?.icon ?? List;
const applyOption = (value: ListType) => {
const chain = editor.chain().focus();
if (value === 'bullet') {
chain.toggleBulletList().run();
} else if (value === 'ordered') {
chain.toggleOrderedList().run();
} else {
chain.toggleTaskList().run();
}
};
return (
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
aria-label={`List: ${active?.label ?? 'None'}`}
className={cn('gap-0.5 px-1.5', active && 'bg-accent text-accent-foreground')}
data-toolbar-item=""
disabled={disabled}
size="sm"
type="button"
variant="ghost"
>
<TriggerIcon />
<ChevronDown className="size-4 opacity-50" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>Lists</TooltipContent>
</Tooltip>
<DropdownMenuContent
align="start"
className="min-w-[160px]"
onCloseAutoFocus={(event) => {
event.preventDefault();
editor.commands.focus();
}}
>
{OPTIONS.map((option) => (
<DropdownMenuItem
key={option.value}
onSelect={() => applyOption(option.value)}
>
<option.icon className="text-muted-foreground size-4 shrink-0" />
<span>{option.label}</span>
{activeType === option.value ? <Check className="ml-auto size-4 shrink-0" /> : null}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,159 @@
import type { Editor } from '@tiptap/react';
import {
AlignLeft,
ArrowDown,
ArrowLeft,
ArrowRight,
ArrowUp,
Check,
ChevronDown,
Delete,
PanelTop,
Table as TableIcon,
Trash2,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Switch } from '@/components/ui/switch';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import { ALIGN_OPTIONS, type ColumnAlign, setColumnAlign } from './markdown-editor-table-commands';
export type { ColumnAlign };
interface TableMenuProps {
columnAlign: ColumnAlign | null;
disabled?: boolean;
editor: Editor;
isActive: boolean;
isHeaderRow: boolean;
}
export function TableMenu({ columnAlign, disabled, editor, isActive, isHeaderRow }: TableMenuProps) {
const run = (fn: (chain: ReturnType<Editor['chain']>) => ReturnType<Editor['chain']>) =>
fn(editor.chain().focus()).run();
return (
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
aria-label="Table"
className={cn('gap-0.5 px-1.5', isActive && 'bg-accent text-accent-foreground')}
data-toolbar-item=""
disabled={disabled}
size="sm"
type="button"
variant="ghost"
>
<TableIcon />
<ChevronDown className="size-4 opacity-50" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>Table</TooltipContent>
</Tooltip>
<DropdownMenuContent
align="start"
className="min-w-[180px]"
onCloseAutoFocus={(event) => {
event.preventDefault();
editor.commands.focus();
}}
>
{isActive ? (
<>
<DropdownMenuItem
aria-checked={isHeaderRow}
onSelect={(event) => {
// Keep the menu open so the switch animates in place, like Notion's block menu.
event.preventDefault();
editor.chain().toggleHeaderRow().run();
}}
role="menuitemcheckbox"
>
<PanelTop className="text-muted-foreground size-4 shrink-0" />
<span>Header row</span>
<Switch
checked={isHeaderRow}
className="pointer-events-none ml-auto"
tabIndex={-1}
/>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => run((chain) => chain.addRowBefore())}>
<ArrowUp className="text-muted-foreground size-4 shrink-0" />
Insert row above
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => run((chain) => chain.addRowAfter())}>
<ArrowDown className="text-muted-foreground size-4 shrink-0" />
Insert row below
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => run((chain) => chain.addColumnBefore())}>
<ArrowLeft className="text-muted-foreground size-4 shrink-0" />
Insert column left
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => run((chain) => chain.addColumnAfter())}>
<ArrowRight className="text-muted-foreground size-4 shrink-0" />
Insert column right
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<AlignLeft className="text-muted-foreground size-4 shrink-0" />
Align column
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{ALIGN_OPTIONS.map((option) => (
<DropdownMenuItem
key={option.value}
onSelect={() => setColumnAlign(editor, option.value)}
>
<option.icon className="text-muted-foreground size-4 shrink-0" />
<span>{option.label}</span>
{(columnAlign ?? 'left') === option.value ? (
<Check className="ml-auto size-4 shrink-0" />
) : null}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => run((chain) => chain.deleteRow())}>
<Delete className="text-muted-foreground size-4 shrink-0 -rotate-90" />
Delete row
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => run((chain) => chain.deleteColumn())}>
<Delete className="text-muted-foreground size-4 shrink-0" />
Delete column
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => run((chain) => chain.deleteTable())}>
<Trash2 className="text-muted-foreground size-4 shrink-0" />
Delete table
</DropdownMenuItem>
</>
) : (
<DropdownMenuItem
onSelect={() => run((chain) => chain.insertTable({ cols: 3, rows: 3, withHeaderRow: true }))}
>
<TableIcon className="text-muted-foreground size-4 shrink-0" />
Insert table
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,57 @@
// Authoring-time protocol allowlists for the toolbar's Link and Image popovers. They keep javascript:/data:text
// (and, for images, script-capable data:image/svg+xml) out of the persisted document so fidelity never depends on
// every future render path sanitizing them. Load/paste bypass these — tiptap Link's isAllowedUri and the
// read-only viewer sanitize protocols on render; an <img src> is inert regardless.
const SAFE_LINK_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']);
const KNOWN_LINK_SCHEME = /^(?:https?:\/\/|mailto:|tel:)/i;
// A scheme-less link ("example.com", "localhost:3000") must be made absolute, or the browser resolves it against
// the current origin as a relative path. Prepend https:// unless the input already carries an allowed scheme, then
// validate the protocol. Validate with NO base URL — passing window.location as base (as a plain protocol check
// would) launders a relative value into https: and is exactly the bug this replaces. Returns the normalized href,
// or null for unsafe/malformed input (javascript:/data:/file:, empty). Neither tiptap nor its UI normalize manual
// entry — both persist the raw href verbatim — so we do it here.
export const normalizeLinkUrl = (raw: string): null | string => {
const url = raw.trim();
if (!url) {
return null;
}
const candidate = KNOWN_LINK_SCHEME.test(url) ? url : `https://${url.replace(/^\/+/, '')}`;
try {
return SAFE_LINK_PROTOCOLS.has(new URL(candidate).protocol) ? candidate : null;
} catch {
return null;
}
};
const RASTER_IMAGE_DATA = /^data:image\/(?:png|jpe?g|gif|webp|bmp);base64,/i;
// Image analog of normalizeLinkUrl: a scheme-less src ("example.com/a.png") is made absolute with https://; an
// already-schemed http(s) URL and a base64 raster data: URL pass through. data:image/svg+xml is rejected — SVG
// can carry script. Returns the normalized src, or null for unsafe/malformed input.
export const normalizeImageSrc = (raw: string): null | string => {
const src = raw.trim();
if (!src) {
return null;
}
const candidate = /^(?:https?:\/\/|data:)/i.test(src) ? src : `https://${src.replace(/^\/+/, '')}`;
if (/^data:/i.test(candidate)) {
return RASTER_IMAGE_DATA.test(candidate) ? candidate : null;
}
try {
const { protocol } = new URL(candidate);
return protocol === 'http:' || protocol === 'https:' ? candidate : null;
} catch {
return null;
}
};
@@ -4,317 +4,374 @@ import { useEditorState } from '@tiptap/react';
import {
Bold,
Code,
Code2,
Heading1,
Heading2,
Heading3,
ImagePlus,
Italic,
Link as LinkIcon,
List,
ListOrdered,
ListTodo,
Minus,
Quote,
Redo,
RemoveFormatting,
SquareCode,
Strikethrough,
Table,
Undo,
} from 'lucide-react';
import { memo, useCallback } from 'react';
import { memo, useEffect, useRef } from 'react';
import { Separator } from '@/components/ui/separator';
import { Toggle } from '@/components/ui/toggle';
import { TooltipProvider } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import type { HeadingLevel } from './markdown-editor-toolbar-heading';
import type { ListType } from './markdown-editor-toolbar-list';
import type { ColumnAlign } from './markdown-editor-toolbar-table';
import { hasHeaderRow } from './markdown-editor-table-commands';
import { ToolbarButton, ToolbarToggle } from './markdown-editor-toolbar-button';
import { HeadingMenu } from './markdown-editor-toolbar-heading';
import { ImagePopover } from './markdown-editor-toolbar-image';
import { LinkPopover } from './markdown-editor-toolbar-link';
import { ListMenu } from './markdown-editor-toolbar-list';
import { TableMenu } from './markdown-editor-toolbar-table';
interface MarkdownEditorToolbarProps {
disabled?: boolean;
editor: Editor;
}
// The Image extension doesn't validate the src protocol, so the toolbar Insert-image button rejects
// non-http(s)/non-raster-data URLs (javascript:, data:text/html, data:image/svg+xml — SVG can carry script).
// Guards ONLY that button — images entering via markdown load/paste bypass it (inert in an <img src>; the
// read-only viewer sanitizes protocols on render).
export const isSafeImageSrc = (url: string): boolean => {
try {
const { protocol } = new URL(url, window.location.href);
const HEADING_LEVELS: HeadingLevel[] = [1, 2, 3, 4, 5, 6];
return (
protocol === 'http:' || protocol === 'https:' || /^data:image\/(png|jpe?g|gif|webp|bmp);base64,/i.test(url)
);
} catch {
return false;
}
};
// A mouse wheel emits deltaY, which the browser applies to the nearest VERTICAL scroller — never to this
// overflow-x strip, so a wheel-mouse user can't reach overflowed controls (trackpads emit deltaX and already
// work). Translate a vertical-dominant wheel into horizontal scroll, releasing at the ends so the page can
// still scroll past the toolbar. Attached non-passively — React's onWheel is passive, so it can't preventDefault.
function useHorizontalWheelScroll(ref: React.RefObject<HTMLDivElement | null>) {
useEffect(() => {
const strip = ref.current;
// Link-toolbar counterpart of isSafeImageSrc: only navigable protocols (relative/anchor URLs resolve to the
// page's http/https). Keeps `javascript:` / `data:` out of the persisted document at authoring time so it
// never depends on every future render path sanitizing them. Load/paste bypass this (tiptap Link's
// isAllowedUri + the read-only viewer sanitize protocols on render).
const SAFE_LINK_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']);
if (!strip) {
return;
}
export const isSafeUrl = (url: string): boolean => {
try {
return SAFE_LINK_PROTOCOLS.has(new URL(url, window.location.href).protocol);
} catch {
return false;
}
};
const handleWheel = (event: WheelEvent) => {
if (strip.scrollWidth <= strip.clientWidth || Math.abs(event.deltaX) >= Math.abs(event.deltaY)) {
return;
}
// memo: every keystroke re-renders the RHF-controlled parent; without it all ~20 Toggle subtrees re-render
const atStart = strip.scrollLeft <= 0 && event.deltaY < 0;
const atEnd = strip.scrollLeft + strip.clientWidth >= strip.scrollWidth && event.deltaY > 0;
if (atStart || atEnd) {
return;
}
strip.scrollLeft += event.deltaY;
event.preventDefault();
};
strip.addEventListener('wheel', handleWheel, { passive: false });
return () => strip.removeEventListener('wheel', handleWheel);
}, [ref]);
}
// WAI-ARIA toolbar pattern: one Tab stop for the whole bar, Arrow/Home/End move between controls. Managed
// imperatively on `[data-toolbar-item]` so each control stays a dumb button; the set changes (the table
// control swaps button↔menu, controls disable) so a MutationObserver re-seeds the single tab stop. When a
// popover/dropdown is open its focus lives in a body portal outside the bar, so activeElement isn't an item
// and Arrow keys fall through to that menu instead of being hijacked here.
function useToolbarRovingFocus(ref: React.RefObject<HTMLDivElement | null>) {
useEffect(() => {
const toolbar = ref.current;
if (!toolbar) {
return;
}
const enabledItems = () =>
Array.from(toolbar.querySelectorAll<HTMLElement>('[data-toolbar-item]')).filter(
(item) => !item.hasAttribute('disabled'),
);
const seedTabStop = () => {
const items = enabledItems();
const active = items.find((item) => item.tabIndex === 0) ?? items[0] ?? null;
for (const item of toolbar.querySelectorAll<HTMLElement>('[data-toolbar-item]')) {
item.tabIndex = item === active ? 0 : -1;
}
};
seedTabStop();
const observer = new MutationObserver(seedTabStop);
observer.observe(toolbar, { attributeFilter: ['disabled'], childList: true, subtree: true });
const handleKeyDown = (event: KeyboardEvent) => {
if (!['ArrowLeft', 'ArrowRight', 'End', 'Home'].includes(event.key)) {
return;
}
const items = enabledItems();
const index = items.indexOf(document.activeElement as HTMLElement);
if (index === -1) {
return;
}
event.preventDefault();
const nextIndex =
event.key === 'Home'
? 0
: event.key === 'End'
? items.length - 1
: event.key === 'ArrowRight'
? (index + 1) % items.length
: (index - 1 + items.length) % items.length;
const next = items[nextIndex];
if (!next) {
return;
}
for (const item of items) {
item.tabIndex = item === next ? 0 : -1;
}
next.focus();
};
toolbar.addEventListener('keydown', handleKeyDown);
return () => {
toolbar.removeEventListener('keydown', handleKeyDown);
observer.disconnect();
};
}, [ref]);
}
// memo: every keystroke re-renders the RHF-controlled parent; without it all Toggle/Button subtrees re-render
// per keystroke for referentially-stable props.
export const MarkdownEditorToolbar = memo(function MarkdownEditorToolbar({
disabled,
editor,
}: MarkdownEditorToolbarProps) {
const handleSetLink = useCallback(() => {
const previousUrl = editor.getAttributes('link').href as string | undefined;
const url = window.prompt('URL', previousUrl ?? '');
if (url === null) {
return;
}
if (url === '') {
editor.chain().focus().extendMarkRange('link').unsetLink().run();
return;
}
if (!isSafeUrl(url)) {
return;
}
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
}, [editor]);
// tiptap v3's useEditor does NOT re-render on every transaction, so reading editor.isActive/can() inline
// would leave the toolbar stale on selection-only moves (click into a bold word → Bold stays unlit).
// useEditorState re-runs this selector per transaction and re-renders only when a button's state flips.
// useEditorState re-runs this selector per transaction and re-renders only when a value flips.
const state = useEditorState({
editor,
selector: ({ editor }) => ({
activeListType: (editor.isActive('bulletList')
? 'bullet'
: editor.isActive('orderedList')
? 'ordered'
: editor.isActive('taskList')
? 'task'
: null) as ListType | null,
canRedo: editor.can().redo(),
canUndo: editor.can().undo(),
columnAlign: (editor.getAttributes('tableHeader').align ??
editor.getAttributes('tableCell').align ??
null) as ColumnAlign | null,
headingLevel: (HEADING_LEVELS.find((level) => editor.isActive('heading', { level })) ?? 0) as
| 0
| HeadingLevel,
isBlockquote: editor.isActive('blockquote'),
isBold: editor.isActive('bold'),
isBulletList: editor.isActive('bulletList'),
isCode: editor.isActive('code'),
isCodeBlock: editor.isActive('codeBlock'),
isH1: editor.isActive('heading', { level: 1 }),
isH2: editor.isActive('heading', { level: 2 }),
isH3: editor.isActive('heading', { level: 3 }),
isHeaderRow: hasHeaderRow(editor),
isItalic: editor.isActive('italic'),
isLink: editor.isActive('link'),
isOrderedList: editor.isActive('orderedList'),
isStrike: editor.isActive('strike'),
isTable: editor.isActive('table'),
isTaskList: editor.isActive('taskList'),
}),
});
const toolbarRef = useRef<HTMLDivElement>(null);
const stripRef = useRef<HTMLDivElement>(null);
useToolbarRovingFocus(toolbarRef);
useHorizontalWheelScroll(stripRef);
return (
<div
className={cn(
'bg-muted/40 flex flex-wrap items-center gap-0.5 border-b px-1 py-1',
disabled && 'pointer-events-none opacity-60',
)}
data-slot="markdown-editor-toolbar"
>
<Toggle
aria-label="Bold"
onPressedChange={() => editor.chain().focus().toggleBold().run()}
pressed={state.isBold}
size="sm"
title="Bold (Ctrl+B)"
<TooltipProvider delayDuration={400}>
<div
aria-label="Formatting"
className={cn(
'bg-muted/40 order-last flex items-center border-t px-1 py-1',
'md:order-first md:border-t-0 md:border-b',
disabled && 'opacity-60',
)}
data-slot="markdown-editor-toolbar"
ref={toolbarRef}
role="toolbar"
>
<Bold />
</Toggle>
<Toggle
aria-label="Italic"
onPressedChange={() => editor.chain().focus().toggleItalic().run()}
pressed={state.isItalic}
size="sm"
title="Italic (Ctrl+I)"
>
<Italic />
</Toggle>
<Toggle
aria-label="Strikethrough"
onPressedChange={() => editor.chain().focus().toggleStrike().run()}
pressed={state.isStrike}
size="sm"
title="Strikethrough"
>
<Strikethrough />
</Toggle>
<Toggle
aria-label="Inline code"
onPressedChange={() => editor.chain().focus().toggleCode().run()}
pressed={state.isCode}
size="sm"
title="Inline code"
>
<Code />
</Toggle>
<Separator
className="mx-1 h-5"
orientation="vertical"
/>
<Toggle
aria-label="Heading 1"
onPressedChange={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
pressed={state.isH1}
size="sm"
title="Heading 1"
>
<Heading1 />
</Toggle>
<Toggle
aria-label="Heading 2"
onPressedChange={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
pressed={state.isH2}
size="sm"
title="Heading 2"
>
<Heading2 />
</Toggle>
<Toggle
aria-label="Heading 3"
onPressedChange={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
pressed={state.isH3}
size="sm"
title="Heading 3"
>
<Heading3 />
</Toggle>
<Separator
className="mx-1 h-5"
orientation="vertical"
/>
<Toggle
aria-label="Bullet list"
onPressedChange={() => editor.chain().focus().toggleBulletList().run()}
pressed={state.isBulletList}
size="sm"
title="Bullet list"
>
<List />
</Toggle>
<Toggle
aria-label="Ordered list"
onPressedChange={() => editor.chain().focus().toggleOrderedList().run()}
pressed={state.isOrderedList}
size="sm"
title="Ordered list"
>
<ListOrdered />
</Toggle>
<Toggle
aria-label="Task list"
onPressedChange={() => editor.chain().focus().toggleTaskList().run()}
pressed={state.isTaskList}
size="sm"
title="Task list"
>
<ListTodo />
</Toggle>
<Separator
className="mx-1 h-5"
orientation="vertical"
/>
<Toggle
aria-label="Blockquote"
onPressedChange={() => editor.chain().focus().toggleBlockquote().run()}
pressed={state.isBlockquote}
size="sm"
title="Blockquote"
>
<Quote />
</Toggle>
<Toggle
aria-label="Code block"
onPressedChange={() => editor.chain().focus().toggleCodeBlock().run()}
pressed={state.isCodeBlock}
size="sm"
title="Code block"
>
<Code2 />
</Toggle>
<Toggle
aria-label="Link"
onPressedChange={handleSetLink}
pressed={state.isLink}
size="sm"
title="Insert link"
>
<LinkIcon />
</Toggle>
<Toggle
aria-label="Insert image"
onPressedChange={() => {
const url = window.prompt('Image URL');
if (url && isSafeImageSrc(url)) {
editor.chain().focus().setImage({ src: url }).run();
}
}}
pressed={false}
size="sm"
title="Insert image"
>
<ImagePlus />
</Toggle>
<Toggle
aria-label="Horizontal rule"
onPressedChange={() => editor.chain().focus().setHorizontalRule().run()}
pressed={false}
size="sm"
title="Horizontal rule"
>
<Minus />
</Toggle>
<Toggle
aria-label="Insert table"
onPressedChange={() =>
editor.chain().focus().insertTable({ cols: 3, rows: 3, withHeaderRow: true }).run()
}
pressed={state.isTable}
size="sm"
title="Insert table"
>
<Table />
</Toggle>
<div className="ml-auto flex items-center gap-0.5">
<Toggle
aria-label="Undo"
disabled={!state.canUndo}
onPressedChange={() => editor.chain().focus().undo().run()}
pressed={false}
size="sm"
title="Undo (Ctrl+Z)"
{/* Scroll strip: controls never wrap — they scroll horizontally. min-w-0 lets this flex child
shrink below its content so the overflow actually engages; Undo/Redo live OUTSIDE it (below) so
they stay pinned right instead of scrolling away (ml-auto collapses to 0 once a flex row overflows). */}
<div
className="flex min-w-0 flex-1 [scrollbar-width:none] items-center gap-0.5 overflow-x-auto overscroll-x-contain [&::-webkit-scrollbar]:hidden [&>*]:shrink-0"
ref={stripRef}
>
<Undo />
</Toggle>
<Toggle
aria-label="Redo"
disabled={!state.canRedo}
onPressedChange={() => editor.chain().focus().redo().run()}
pressed={false}
size="sm"
title="Redo (Ctrl+Shift+Z)"
<HeadingMenu
activeLevel={state.headingLevel}
disabled={disabled}
editor={editor}
/>
<Separator
className="mx-1 h-5"
orientation="vertical"
/>
<div
className="flex items-center gap-0.5"
role="group"
>
<ToolbarToggle
disabled={disabled}
label="Bold"
onPressedChange={() => editor.chain().focus().toggleBold().run()}
pressed={state.isBold}
shortcut="⌘B"
>
<Bold />
</ToolbarToggle>
<ToolbarToggle
disabled={disabled}
label="Italic"
onPressedChange={() => editor.chain().focus().toggleItalic().run()}
pressed={state.isItalic}
shortcut="⌘I"
>
<Italic />
</ToolbarToggle>
<ToolbarToggle
disabled={disabled}
label="Strikethrough"
onPressedChange={() => editor.chain().focus().toggleStrike().run()}
pressed={state.isStrike}
>
<Strikethrough />
</ToolbarToggle>
<ToolbarToggle
disabled={disabled}
label="Inline code"
onPressedChange={() => editor.chain().focus().toggleCode().run()}
pressed={state.isCode}
>
<Code />
</ToolbarToggle>
<LinkPopover
disabled={disabled}
editor={editor}
isActive={state.isLink}
/>
</div>
<Separator
className="mx-1 h-5"
orientation="vertical"
/>
<ListMenu
activeType={state.activeListType}
disabled={disabled}
editor={editor}
/>
<Separator
className="mx-1 h-5"
orientation="vertical"
/>
<TableMenu
columnAlign={state.columnAlign}
disabled={disabled}
editor={editor}
isActive={state.isTable}
isHeaderRow={state.isHeaderRow}
/>
<Separator
className="mx-1 h-5"
orientation="vertical"
/>
<div
className="flex items-center gap-0.5"
role="group"
>
<ToolbarToggle
disabled={disabled}
label="Blockquote"
onPressedChange={() => editor.chain().focus().toggleBlockquote().run()}
pressed={state.isBlockquote}
>
<Quote />
</ToolbarToggle>
<ToolbarToggle
disabled={disabled}
label="Code block"
onPressedChange={() => editor.chain().focus().toggleCodeBlock().run()}
pressed={state.isCodeBlock}
>
<SquareCode />
</ToolbarToggle>
<ImagePopover
disabled={disabled}
editor={editor}
/>
<ToolbarButton
disabled={disabled}
label="Horizontal rule"
onClick={() => editor.chain().focus().setHorizontalRule().run()}
>
<Minus />
</ToolbarButton>
</div>
<Separator
className="mx-1 h-5"
orientation="vertical"
/>
<ToolbarButton
disabled={disabled}
label="Clear formatting"
onClick={() => editor.chain().focus().unsetAllMarks().clearNodes().run()}
>
<RemoveFormatting />
</ToolbarButton>
</div>
<Separator
className="mx-1 h-5"
orientation="vertical"
/>
<div
className="flex shrink-0 items-center gap-0.5"
role="group"
>
<Redo />
</Toggle>
<ToolbarButton
disabled={disabled || !state.canUndo}
label="Undo"
onClick={() => editor.chain().focus().undo().run()}
shortcut="⌘Z"
>
<Undo />
</ToolbarButton>
<ToolbarButton
disabled={disabled || !state.canRedo}
label="Redo"
onClick={() => editor.chain().focus().redo().run()}
shortcut="⇧⌘Z"
>
<Redo />
</ToolbarButton>
</div>
</div>
</div>
</TooltipProvider>
);
});
@@ -10,7 +10,10 @@ import { useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react
import { cn } from '@/lib/utils';
import { createMarkdownExtensions } from './markdown-editor-extensions';
import { ImageHandle } from './markdown-editor-image-handle';
import { LinkHandle } from './markdown-editor-link-handle';
import { MARKDOWN_EDITOR_WRAPPER_CLASS as WRAPPER_CLASS } from './markdown-editor-styles';
import { TableHandles } from './markdown-editor-table-handles';
import { MarkdownEditorToolbar } from './markdown-editor-toolbar';
import { findVariableOccurrences } from './markdown-editor-variable-highlight';
import { nextVariableRange } from './markdown-editor-variable-syntax';
@@ -33,6 +36,16 @@ interface MarkdownEditorProps {
value: string;
}
interface UseMarkdownEditorOptions extends Pick<AriaAttributes, 'aria-describedby' | 'aria-invalid'> {
disabled?: boolean;
handleRef?: Ref<MarkdownEditorHandle>;
id?: string;
onBlur?: () => void;
onChange: (value: string) => void;
placeholder?: string;
value: string;
}
export const resetUndoHistory = (editor: Editor): void => {
const { state, view } = editor;
// A fresh history() shares prosemirror-history's module-level singleton PluginKey, so match the
@@ -77,10 +90,81 @@ function MarkdownEditor({
id,
onBlur,
onChange,
placeholder = 'Write something…',
placeholder,
ref,
value,
}: MarkdownEditorProps & { ref?: Ref<MarkdownEditorHandle> }) {
const editor = useMarkdownEditor({
'aria-describedby': ariaDescribedby,
'aria-invalid': ariaInvalid,
disabled,
handleRef: ref,
id,
onBlur,
onChange,
placeholder,
value,
});
if (!editor) {
return (
<div
aria-busy="true"
className={cn(
WRAPPER_CLASS,
'items-center justify-center',
disabled && 'pointer-events-none opacity-60',
className,
)}
data-slot="markdown-editor"
>
<Loader2 className="text-muted-foreground size-5 animate-spin" />
</div>
);
}
return (
<div
className={cn(
WRAPPER_CLASS,
'focus-within:ring-ring focus-within:ring-1',
disabled && 'pointer-events-none opacity-60',
className,
)}
data-slot="markdown-editor"
>
<MarkdownEditorToolbar
disabled={disabled}
editor={editor}
/>
<EditorContent
className={cn(
'prose prose-sm dark:prose-invert tiptap-content max-w-none min-w-0 flex-1 overflow-auto px-3 py-2',
'[&_.ProseMirror]:min-h-full [&_.ProseMirror]:outline-none',
)}
editor={editor}
/>
{!disabled && <TableHandles editor={editor} />}
{!disabled && <LinkHandle editor={editor} />}
{!disabled && <ImageHandle editor={editor} />}
</div>
);
}
// Owns the whole tiptap lifecycle for a markdown-controlled editor: instance creation, the RHF value-sync loop
// (echo suppression + undo reset), disabled toggling, a11y-attribute forwarding, and the imperative handle. The
// view below just renders whatever editor this returns — the lifecycle can be reworked here without touching it.
function useMarkdownEditor({
'aria-describedby': ariaDescribedby,
'aria-invalid': ariaInvalid,
disabled,
handleRef,
id,
onBlur,
onChange,
placeholder = 'Write something…',
value,
}: UseMarkdownEditorOptions): Editor | null {
// Suppress echoes of our own output: the markdown round-trip re-serializes slightly (whitespace/list
// markers/blank lines), and those normalizations must not flip RHF's isDirty as if the user had edited.
const lastEmittedRef = useRef<string>(value);
@@ -131,7 +215,7 @@ function MarkdownEditor({
});
useImperativeHandle(
ref,
handleRef,
() => ({
focus: () => {
editor?.commands.focus();
@@ -246,46 +330,7 @@ function MarkdownEditor({
}
}, [editor, ariaDescribedby, ariaInvalid, id]);
if (!editor) {
return (
<div
aria-busy="true"
className={cn(
WRAPPER_CLASS,
'items-center justify-center',
disabled && 'pointer-events-none opacity-60',
className,
)}
data-slot="markdown-editor"
>
<Loader2 className="text-muted-foreground size-5 animate-spin" />
</div>
);
}
return (
<div
className={cn(
WRAPPER_CLASS,
'focus-within:ring-ring focus-within:ring-1',
disabled && 'pointer-events-none opacity-60',
className,
)}
data-slot="markdown-editor"
>
<MarkdownEditorToolbar
disabled={disabled}
editor={editor}
/>
<EditorContent
className={cn(
'prose prose-sm dark:prose-invert tiptap-content max-w-none min-w-0 flex-1 overflow-auto px-3 py-2',
'[&_.ProseMirror]:min-h-full [&_.ProseMirror]:outline-none',
)}
editor={editor}
/>
</div>
);
return editor;
}
export { MarkdownEditor };
+2 -2
View File
@@ -81,7 +81,7 @@ function SelectScrollDownButton({
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
<ChevronDown className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
@@ -117,7 +117,7 @@ function SelectTrigger({ children, className, ...props }: React.ComponentProps<t
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
<ChevronDown className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);