feat: implementa editor de texto rico com TipTap

- Adiciona editor WYSIWYG com formatação básica
- Implementa contagem de palavras em tempo real
- Adiciona barra de ferramentas de formatação
- Suporte a alinhamento de texto e destaque
This commit is contained in:
Lucas Santana 2025-02-06 21:44:56 -03:00
parent 1bc307d599
commit 2929946499
5 changed files with 1356 additions and 140 deletions

938
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -31,8 +31,19 @@
"@radix-ui/react-toast": "^1.2.4",
"@sentry/react": "^8.48.0",
"@supabase/supabase-js": "^2.39.7",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/react-query": "^5.62.8",
"@testing-library/react": "^16.1.0",
"@tiptap/extension-character-count": "^2.11.5",
"@tiptap/extension-color": "^2.11.5",
"@tiptap/extension-highlight": "^2.11.5",
"@tiptap/extension-placeholder": "^2.11.5",
"@tiptap/extension-text-align": "^2.11.5",
"@tiptap/extension-text-style": "^2.11.5",
"@tiptap/extension-underline": "^2.11.5",
"@tiptap/pm": "^2.11.5",
"@tiptap/react": "^2.11.5",
"@tiptap/starter-kit": "^2.11.5",
"@tremor/react": "^3.18.7",
"@types/ioredis": "^4.28.10",
"@types/jest": "^29.5.14",
@ -50,6 +61,7 @@
"resend": "^3.2.0",
"shadcn-ui": "^0.9.4",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"uuid": "^11.0.3",
"vitest": "^2.1.8"
},

View File

@ -0,0 +1,188 @@
import { useEditor, EditorContent, Editor as TiptapEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Placeholder from '@tiptap/extension-placeholder'
import CharacterCount from '@tiptap/extension-character-count'
import Highlight from '@tiptap/extension-highlight'
import TextAlign from '@tiptap/extension-text-align'
import Underline from '@tiptap/extension-underline'
import TextStyle from '@tiptap/extension-text-style'
import Color from '@tiptap/extension-color'
import { useEffect } from 'react'
import { cn } from '@/lib/utils'
import { Button } from './button'
import {
Bold,
Italic,
Underline as UnderlineIcon,
AlignLeft,
AlignCenter,
AlignRight,
Highlighter,
} from 'lucide-react'
interface EditorProps {
content: string
onChange: (content: string) => void
placeholder?: string
className?: string
minHeight?: string
readOnly?: boolean
}
interface MenuBarProps {
editor: TiptapEditor | null
}
function MenuBar({ editor }: MenuBarProps) {
if (!editor) {
return null
}
return (
<div className="border-b border-input bg-transparent p-1">
<div className="flex flex-wrap gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleBold().run()}
className={cn(editor.isActive('bold') && 'bg-muted')}
aria-label="Negrito"
>
<Bold className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleItalic().run()}
className={cn(editor.isActive('italic') && 'bg-muted')}
aria-label="Itálico"
>
<Italic className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleUnderline().run()}
className={cn(editor.isActive('underline') && 'bg-muted')}
aria-label="Sublinhado"
>
<UnderlineIcon className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleHighlight().run()}
className={cn(editor.isActive('highlight') && 'bg-muted')}
aria-label="Destacar"
>
<Highlighter className="h-4 w-4" />
</Button>
<div className="mx-2 w-[1px] bg-border" />
<Button
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().setTextAlign('left').run()}
className={cn(editor.isActive({ textAlign: 'left' }) && 'bg-muted')}
aria-label="Alinhar à esquerda"
>
<AlignLeft className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().setTextAlign('center').run()}
className={cn(editor.isActive({ textAlign: 'center' }) && 'bg-muted')}
aria-label="Centralizar"
>
<AlignCenter className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().setTextAlign('right').run()}
className={cn(editor.isActive({ textAlign: 'right' }) && 'bg-muted')}
aria-label="Alinhar à direita"
>
<AlignRight className="h-4 w-4" />
</Button>
</div>
</div>
)
}
export function Editor({
content,
onChange,
placeholder = 'Comece a escrever...',
className,
minHeight = '500px',
readOnly = false,
}: EditorProps) {
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: false,
codeBlock: false,
horizontalRule: false,
table: false,
}),
Placeholder.configure({
placeholder,
emptyEditorClass: 'is-editor-empty',
}),
CharacterCount,
Highlight.configure({
multicolor: true,
}),
TextAlign.configure({
types: ['paragraph'],
}),
Underline,
TextStyle,
Color,
],
content,
onUpdate: ({ editor }) => {
onChange(editor.getHTML())
},
editable: !readOnly,
})
useEffect(() => {
if (editor && content !== editor.getHTML()) {
editor.commands.setContent(content)
}
}, [content, editor])
return (
<div
className={cn(
'rounded-md border border-input bg-transparent',
'focus-within:outline-none focus-within:ring-2',
'focus-within:ring-ring focus-within:ring-offset-2',
className
)}
>
{!readOnly && <MenuBar editor={editor} />}
<div
className={cn(
'prose prose-purple max-w-none px-4 py-2',
'[&_.is-editor-empty]:before:text-muted-foreground',
'[&_.is-editor-empty]:before:content-[attr(data-placeholder)]',
'[&_.is-editor-empty]:before:float-left',
'[&_.is-editor-empty]:before:h-0',
'[&_.is-editor-empty]:before:pointer-events-none',
readOnly && 'prose-sm'
)}
style={{ minHeight }}
>
<EditorContent editor={editor} />
</div>
{!readOnly && editor && (
<div className="border-t border-input px-4 py-2 text-sm text-muted-foreground">
{editor.storage.characterCount.words()} palavras
</div>
)}
</div>
)
}

View File

@ -22,6 +22,7 @@ import { useUppercasePreference } from '@/hooks/useUppercasePreference';
import { AdaptiveText, AdaptiveTitle, AdaptiveParagraph } from '@/components/ui/adaptive-text';
import { TextCaseToggle } from '@/components/ui/text-case-toggle';
import { cn } from '@/lib/utils';
import { Editor } from '@/components/ui/editor';
interface Essay {
id: string;
@ -282,11 +283,11 @@ export function EssayPage() {
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<div className="md:col-span-3">
<Textarea
value={essay.content}
onChange={(e) => setEssay({ ...essay, content: e.target.value })}
className="min-h-[500px] font-mono resize-none p-4"
<Editor
content={essay.content}
onChange={(newContent) => setEssay({ ...essay, content: newContent })}
placeholder="Escreva sua redação aqui..."
readOnly={essay.status !== 'draft'}
/>
<div className="mt-2 text-sm">
<span className={cn(

View File

@ -1,87 +1,288 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
module.exports = {
darkMode: ["class"],
content: [
'./pages/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
'./app/**/*.{ts,tsx}',
'./src/**/*.{ts,tsx}',
],
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'sans-serif'],
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
purple: {
50: '#f5f3ff',
100: '#ede9fe',
200: '#ddd6fe',
300: '#c4b5fd',
400: '#a78bfa',
500: '#8b5cf6',
600: '#7c3aed',
700: '#6d28d9',
800: '#5b21b6',
900: '#4c1d95',
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
blue: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: 0 },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: 0 },
},
},
animation: {
'in': 'in 200ms ease-in',
'out': 'out 200ms ease-out',
'slide-in-from-top': 'slide-in-from-top 200ms ease-out',
'slide-in-from-bottom': 'slide-in-from-bottom 200ms ease-out',
'slide-out-to-right': 'slide-out-to-right 200ms ease-out',
'fade-in': 'fade-in 200ms ease-in',
'fade-out': 'fade-out 200ms ease-out',
'scale-in': 'scale-in 200ms ease-out',
'scale-out': 'scale-out 200ms ease-in',
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
keyframes: {
in: {
'0%': { transform: 'translateX(100%)' },
'100%': { transform: 'translateX(0)' },
},
out: {
'0%': { transform: 'translateX(0)' },
'100%': { transform: 'translateX(100%)' },
},
'slide-in-from-top': {
'0%': { transform: 'translateY(-100%)' },
'100%': { transform: 'translateY(0)' },
},
'slide-in-from-bottom': {
'0%': { transform: 'translateY(100%)' },
'100%': { transform: 'translateY(0)' },
},
'slide-out-to-right': {
'0%': { transform: 'translateX(0)' },
'100%': { transform: 'translateX(100%)' },
},
'fade-in': {
'0%': { opacity: 0 },
'100%': { opacity: 1 },
},
'fade-out': {
'0%': { opacity: 1 },
'100%': { opacity: 0 },
},
'scale-in': {
'0%': { transform: 'scale(0.95)', opacity: 0 },
'100%': { transform: 'scale(1)', opacity: 1 },
},
'scale-out': {
'0%': { transform: 'scale(1)', opacity: 1 },
'100%': { transform: 'scale(0.95)', opacity: 0 },
typography: {
DEFAULT: {
css: {
maxWidth: '100%',
color: 'var(--tw-prose-body)',
'[class~="lead"]': {
color: 'var(--tw-prose-lead)',
},
a: {
color: 'var(--tw-prose-links)',
textDecoration: 'underline',
fontWeight: '500',
},
strong: {
color: 'var(--tw-prose-bold)',
fontWeight: '600',
},
'ol[type="A"]': {
'--list-counter-style': 'upper-alpha',
},
'ol[type="a"]': {
'--list-counter-style': 'lower-alpha',
},
'ol[type="A" s]': {
'--list-counter-style': 'upper-alpha',
},
'ol[type="a" s]': {
'--list-counter-style': 'lower-alpha',
},
'ol[type="I"]': {
'--list-counter-style': 'upper-roman',
},
'ol[type="i"]': {
'--list-counter-style': 'lower-roman',
},
'ol[type="I" s]': {
'--list-counter-style': 'upper-roman',
},
'ol[type="i" s]': {
'--list-counter-style': 'lower-roman',
},
'ol[type="1"]': {
'--list-counter-style': 'decimal',
},
'ol > li': {
position: 'relative',
},
'ol > li::before': {
content: 'counter(list-item, var(--list-counter-style, decimal)) "."',
position: 'absolute',
fontWeight: '400',
color: 'var(--tw-prose-counters)',
},
'ul > li': {
position: 'relative',
},
'ul > li::before': {
content: '""',
position: 'absolute',
backgroundColor: 'var(--tw-prose-bullets)',
borderRadius: '50%',
},
hr: {
borderColor: 'var(--tw-prose-hr)',
borderTopWidth: 1,
},
blockquote: {
fontWeight: '500',
fontStyle: 'italic',
color: 'var(--tw-prose-quotes)',
borderLeftWidth: '0.25rem',
borderLeftColor: 'var(--tw-prose-quote-borders)',
quotes: '"\\201C""\\201D""\\2018""\\2019"',
},
'blockquote p:first-of-type::before': {
content: 'open-quote',
},
'blockquote p:last-of-type::after': {
content: 'close-quote',
},
h1: {
color: 'var(--tw-prose-headings)',
fontWeight: '800',
},
'h1 strong': {
fontWeight: '900',
color: 'inherit',
},
h2: {
color: 'var(--tw-prose-headings)',
fontWeight: '700',
},
'h2 strong': {
fontWeight: '800',
color: 'inherit',
},
h3: {
color: 'var(--tw-prose-headings)',
fontWeight: '600',
},
'h3 strong': {
fontWeight: '700',
color: 'inherit',
},
h4: {
color: 'var(--tw-prose-headings)',
fontWeight: '600',
},
'h4 strong': {
fontWeight: '700',
color: 'inherit',
},
img: {
marginTop: '2em',
marginBottom: '2em',
},
'figure > *': {
marginTop: '0',
marginBottom: '0',
},
figcaption: {
color: 'var(--tw-prose-captions)',
fontSize: '0.875em',
lineHeight: '1.4285714',
marginTop: '0.8571429em',
},
code: {
color: 'var(--tw-prose-code)',
fontWeight: '600',
},
'code::before': {
content: '"`"',
},
'code::after': {
content: '"`"',
},
'a code': {
color: 'inherit',
},
'h1 code': {
color: 'inherit',
},
'h2 code': {
color: 'inherit',
},
'h3 code': {
color: 'inherit',
},
'h4 code': {
color: 'inherit',
},
'blockquote code': {
color: 'inherit',
},
'thead th code': {
color: 'inherit',
},
pre: {
color: 'var(--tw-prose-pre-code)',
backgroundColor: 'var(--tw-prose-pre-bg)',
overflowX: 'auto',
fontWeight: '400',
},
'pre code': {
backgroundColor: 'transparent',
borderWidth: '0',
borderRadius: '0',
padding: '0',
fontWeight: 'inherit',
color: 'inherit',
fontSize: 'inherit',
fontFamily: 'inherit',
lineHeight: 'inherit',
},
'pre code::before': {
content: 'none',
},
'pre code::after': {
content: 'none',
},
table: {
width: '100%',
tableLayout: 'auto',
textAlign: 'left',
marginTop: '2em',
marginBottom: '2em',
},
thead: {
borderBottomWidth: '1px',
borderBottomColor: 'var(--tw-prose-th-borders)',
},
'thead th': {
color: 'var(--tw-prose-headings)',
fontWeight: '600',
verticalAlign: 'bottom',
},
'tbody tr': {
borderBottomWidth: '1px',
borderBottomColor: 'var(--tw-prose-td-borders)',
},
'tbody tr:last-child': {
borderBottomWidth: '0',
},
'tbody td': {
verticalAlign: 'baseline',
},
},
},
},
},
},
plugins: [],
};
plugins: [
require("tailwindcss-animate"),
require('@tailwindcss/typography'),
],
}