mirror of
https://github.com/lucasrcsantana/story-generator.git
synced 2025-12-16 21:37:51 +00:00
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:
parent
1bc307d599
commit
2929946499
938
package-lock.json
generated
938
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@ -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"
|
||||
},
|
||||
|
||||
188
src/components/ui/editor.tsx
Normal file
188
src/components/ui/editor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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'),
|
||||
],
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user