agent-ecosystem/src/renderer/components/ui/tiptap/TiptapToolbar.tsx
iliya 37a4c458bb feat: add Tiptap WYSIWYG editor component for task descriptions
Replace Textarea + Edit/Preview tabs with a reusable WYSIWYG editor
based on Tiptap v3. Integrated into TaskDetailDialog for editing
task descriptions with native markdown I/O.

Components:
- TiptapEditor: main component with EditorContext.Provider pattern
- TiptapToolbar: configurable toolbar with useEditorState for v3 reactivity
- TiptapBubbleMenu: floating formatting menu on text selection
- useTiptapEditor: core hook with markdown I/O, content sync, stale closure prevention
- presets: full/compact/minimal editor configurations
- tiptapStyles.css: ProseMirror styles matching MarkdownViewer values

Key details:
- Data format stays markdown string (not HTML)
- contentType: 'markdown' for both init and setContent
- GFM enabled via markedOptions
- ProseMirror li>p margin fix, nested list styles, gapcursor
2026-03-14 22:17:58 +02:00

271 lines
7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCurrentEditor, useEditorState } from '@tiptap/react';
import {
Bold,
Code,
FileCode2,
Heading1,
Heading2,
Heading3,
Italic,
List,
ListOrdered,
Minus,
Quote,
Redo2,
Strikethrough,
Undo2,
} from 'lucide-react';
import { cn } from '@renderer/lib/utils';
import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip';
import type { ToolbarConfig } from './types';
interface TiptapToolbarProps {
config?: ToolbarConfig;
}
function ToolbarButton({
icon,
active,
disabled,
onClick,
label,
}: {
icon: React.ReactNode;
active?: boolean;
disabled?: boolean;
onClick: () => void;
label: string;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={label}
aria-pressed={active}
className={cn(
'rounded p-1.5 transition-colors',
'text-[var(--color-text-muted)]',
'hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text-secondary)]',
active && 'bg-[var(--color-surface-raised)] text-[var(--color-text)]',
disabled &&
'cursor-not-allowed opacity-30 hover:bg-transparent hover:text-[var(--color-text-muted)]'
)}
>
{icon}
</button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{label}
</TooltipContent>
</Tooltip>
);
}
function Divider() {
return <div className="mx-0.5 h-4 w-px bg-[var(--color-border)]" />;
}
export function TiptapToolbar({ config }: TiptapToolbarProps) {
const { editor } = useCurrentEditor();
// useEditorState — КРИТИЧНО для v3!
// Без этого active state НЕ обновляется (shouldRerenderOnTransaction: false)
const state = useEditorState({
editor,
selector: ({ editor: e }) => {
if (!e) return null;
return {
isBold: e.isActive('bold'),
isItalic: e.isActive('italic'),
isStrike: e.isActive('strike'),
isCode: e.isActive('code'),
isCodeBlock: e.isActive('codeBlock'),
isBulletList: e.isActive('bulletList'),
isOrderedList: e.isActive('orderedList'),
isBlockquote: e.isActive('blockquote'),
headingLevel:
([1, 2, 3] as const).find((l) => e.isActive('heading', { level: l })) ?? 0,
canUndo: e.can().undo(),
canRedo: e.can().redo(),
};
},
});
if (!editor || !state) return null;
const c = {
bold: true,
italic: true,
strike: true,
code: true,
codeBlock: true,
heading: { levels: [1, 2, 3] as (1 | 2 | 3)[] },
bulletList: true,
orderedList: true,
blockquote: true,
horizontalRule: true,
undoRedo: true,
...config,
};
const headingLevels = c.heading === false ? [] : (c.heading?.levels ?? [1, 2, 3]);
const groups: React.ReactNode[][] = [];
// Group 1: Text formatting
const textGroup: React.ReactNode[] = [];
if (c.bold)
textGroup.push(
<ToolbarButton
key="bold"
icon={<Bold size={14} />}
active={state.isBold}
onClick={() => editor.chain().focus().toggleBold().run()}
label="Bold (⌘B)"
/>
);
if (c.italic)
textGroup.push(
<ToolbarButton
key="italic"
icon={<Italic size={14} />}
active={state.isItalic}
onClick={() => editor.chain().focus().toggleItalic().run()}
label="Italic (⌘I)"
/>
);
if (c.strike)
textGroup.push(
<ToolbarButton
key="strike"
icon={<Strikethrough size={14} />}
active={state.isStrike}
onClick={() => editor.chain().focus().toggleStrike().run()}
label="Strikethrough (⌘⇧S)"
/>
);
if (textGroup.length) groups.push(textGroup);
// Group 2: Code
const codeGroup: React.ReactNode[] = [];
if (c.code)
codeGroup.push(
<ToolbarButton
key="code"
icon={<Code size={14} />}
active={state.isCode}
onClick={() => editor.chain().focus().toggleCode().run()}
label="Code (⌘E)"
/>
);
if (c.codeBlock)
codeGroup.push(
<ToolbarButton
key="codeBlock"
icon={<FileCode2 size={14} />}
active={state.isCodeBlock}
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
label="Code Block (⌘⌥C)"
/>
);
if (codeGroup.length) groups.push(codeGroup);
// Group 3: Headings
const headingIcons = { 1: Heading1, 2: Heading2, 3: Heading3 } as const;
const headingGroup: React.ReactNode[] = headingLevels.map((level) => {
const Icon = headingIcons[level];
return (
<ToolbarButton
key={`h${level}`}
icon={<Icon size={14} />}
active={state.headingLevel === level}
onClick={() => editor.chain().focus().toggleHeading({ level }).run()}
label={`Heading ${level}`}
/>
);
});
if (headingGroup.length) groups.push(headingGroup);
// Group 4: Lists
const listGroup: React.ReactNode[] = [];
if (c.bulletList)
listGroup.push(
<ToolbarButton
key="bullet"
icon={<List size={14} />}
active={state.isBulletList}
onClick={() => editor.chain().focus().toggleBulletList().run()}
label="Bullet List (⌘⇧8)"
/>
);
if (c.orderedList)
listGroup.push(
<ToolbarButton
key="ordered"
icon={<ListOrdered size={14} />}
active={state.isOrderedList}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
label="Ordered List (⌘⇧7)"
/>
);
if (listGroup.length) groups.push(listGroup);
// Group 5: Blocks
const blockGroup: React.ReactNode[] = [];
if (c.blockquote)
blockGroup.push(
<ToolbarButton
key="quote"
icon={<Quote size={14} />}
active={state.isBlockquote}
onClick={() => editor.chain().focus().toggleBlockquote().run()}
label="Blockquote (⌘⇧B)"
/>
);
if (c.horizontalRule)
blockGroup.push(
<ToolbarButton
key="hr"
icon={<Minus size={14} />}
onClick={() => editor.chain().focus().setHorizontalRule().run()}
label="Horizontal Rule"
/>
);
if (blockGroup.length) groups.push(blockGroup);
// Group 6: Undo/Redo
if (c.undoRedo) {
groups.push([
<ToolbarButton
key="undo"
icon={<Undo2 size={14} />}
disabled={!state.canUndo}
onClick={() => editor.chain().focus().undo().run()}
label="Undo (⌘Z)"
/>,
<ToolbarButton
key="redo"
icon={<Redo2 size={14} />}
disabled={!state.canRedo}
onClick={() => editor.chain().focus().redo().run()}
label="Redo (⌘⇧Z)"
/>,
]);
}
return (
<div className="flex flex-wrap items-center gap-0.5 border-b border-[var(--color-border)] px-1.5 py-1">
{groups.map((group, i) => (
<div key={i} className="contents">
{i > 0 && <Divider />}
{group}
</div>
))}
</div>
);
}