234 lines
10 KiB
TypeScript
234 lines
10 KiB
TypeScript
'use client';
|
|
|
|
import React, { useMemo, useEffect, useState } from 'react';
|
|
import { useEditor, EditorContent, BubbleMenu, FloatingMenu } from '@tiptap/react';
|
|
import StarterKit from '@tiptap/starter-kit';
|
|
import Placeholder from '@tiptap/extension-placeholder';
|
|
import TaskList from '@tiptap/extension-task-list';
|
|
import TaskItem from '@tiptap/extension-task-item';
|
|
import Heading from '@tiptap/extension-heading';
|
|
import {
|
|
BoldIcon,
|
|
ItalicIcon,
|
|
ListBulletIcon,
|
|
HashtagIcon,
|
|
CodeBracketIcon,
|
|
PlusIcon,
|
|
Bars2Icon
|
|
} from "@heroicons/react/24/outline";
|
|
|
|
interface NotionEditorProps {
|
|
initialContent: string;
|
|
onChange: (jsonContent: string) => void;
|
|
documentId?: string;
|
|
}
|
|
|
|
export default function NotionEditor({ initialContent, onChange, documentId }: NotionEditorProps) {
|
|
const editor = useEditor({
|
|
extensions: [
|
|
StarterKit.configure({
|
|
heading: false,
|
|
}),
|
|
Heading.configure({
|
|
levels: [1, 2, 3],
|
|
}),
|
|
Placeholder.configure({
|
|
placeholder: "Comece a digitar ou aperte '/' para comandos...",
|
|
emptyEditorClass: 'is-editor-empty',
|
|
}),
|
|
TaskList,
|
|
TaskItem.configure({
|
|
nested: true,
|
|
}),
|
|
],
|
|
content: '',
|
|
onUpdate: ({ editor }) => {
|
|
const json = editor.getJSON();
|
|
onChange(JSON.stringify(json));
|
|
},
|
|
editorProps: {
|
|
attributes: {
|
|
class: 'prose prose-zinc dark:prose-invert max-w-none focus:outline-none min-h-[600px] py-10 px-2 editor-content',
|
|
},
|
|
},
|
|
}, [documentId]);
|
|
|
|
// Sincronizar apenas na primeira carga ou troca de documento
|
|
useEffect(() => {
|
|
if (!editor || !initialContent) return;
|
|
|
|
try {
|
|
const parsed = JSON.parse(initialContent);
|
|
// Evita resetar se o editor já tem conteúdo (para não quebrar a digitação)
|
|
if (editor.isEmpty && initialContent !== '{"type":"doc","content":[{"type":"paragraph"}]}') {
|
|
editor.commands.setContent(parsed, false);
|
|
}
|
|
} catch (e) {
|
|
if (editor.isEmpty && initialContent !== '[]') {
|
|
editor.commands.setContent(initialContent, false);
|
|
}
|
|
}
|
|
}, [editor, documentId]); // Só roda quando o editor é criado ou o documento muda
|
|
|
|
if (!editor) return null;
|
|
|
|
return (
|
|
<div className="w-full relative min-h-[600px] group/editor selection:bg-brand-100 dark:selection:bg-brand-500/30">
|
|
{/* Bubble Menu Customizado */}
|
|
<BubbleMenu editor={editor} tippyOptions={{ duration: 150 }} className="flex bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-xl shadow-2xl p-1 gap-0.5 overflow-hidden animate-in fade-in zoom-in-95 duration-200">
|
|
<MenuButton
|
|
onClick={() => editor.chain().focus().toggleBold().run()}
|
|
active={editor.isActive('bold')}
|
|
icon={<BoldIcon className="w-4 h-4" />}
|
|
/>
|
|
<MenuButton
|
|
onClick={() => editor.chain().focus().toggleItalic().run()}
|
|
active={editor.isActive('italic')}
|
|
icon={<ItalicIcon className="w-4 h-4" />}
|
|
/>
|
|
<div className="w-px h-4 bg-zinc-200 dark:bg-zinc-800 mx-1 self-center" />
|
|
<MenuButton
|
|
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
|
active={editor.isActive('heading', { level: 1 })}
|
|
label="H1"
|
|
/>
|
|
<MenuButton
|
|
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
|
active={editor.isActive('heading', { level: 2 })}
|
|
label="H2"
|
|
/>
|
|
<MenuButton
|
|
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
|
active={editor.isActive('bulletList')}
|
|
icon={<ListBulletIcon className="w-4 h-4" />}
|
|
/>
|
|
</BubbleMenu>
|
|
|
|
{/* Menu Flutuante Estilo Notion */}
|
|
<FloatingMenu editor={editor} tippyOptions={{ duration: 150 }} className="flex bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-2xl shadow-2xl p-2 gap-1 overflow-hidden min-w-[240px] flex-col animate-in slide-in-from-left-4 duration-300">
|
|
<p className="px-3 py-1 text-[10px] font-black uppercase tracking-widest text-zinc-400">Blocos Básicos</p>
|
|
<FloatingItem
|
|
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
|
icon={<HashtagIcon className="w-4 h-4 text-brand-500" />}
|
|
title="Título 1"
|
|
desc="Título de seção grande"
|
|
/>
|
|
<FloatingItem
|
|
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
|
icon={<HashtagIcon className="w-4 h-4 text-brand-600" />}
|
|
title="Título 2"
|
|
desc="Título médio"
|
|
/>
|
|
<FloatingItem
|
|
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
|
icon={<ListBulletIcon className="w-4 h-4 text-emerald-500" />}
|
|
title="Lista"
|
|
desc="Lista simples com marcadores"
|
|
/>
|
|
<FloatingItem
|
|
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
|
icon={<CodeBracketIcon className="w-4 h-4 text-zinc-600" />}
|
|
title="Código"
|
|
desc="Bloco para trechos de código"
|
|
/>
|
|
</FloatingMenu>
|
|
|
|
{/* Área do Editor */}
|
|
<div className="relative editor-shell">
|
|
<EditorContent editor={editor} />
|
|
</div>
|
|
|
|
<style dangerouslySetInnerHTML={{
|
|
__html: `
|
|
.tiptap p.is-editor-empty:first-child::before {
|
|
content: attr(data-placeholder);
|
|
float: left;
|
|
color: #adb5bd;
|
|
pointer-events: none;
|
|
height: 0;
|
|
}
|
|
.tiptap {
|
|
font-size: 1.15rem;
|
|
line-height: 1.8;
|
|
color: #374151;
|
|
padding-bottom: 200px;
|
|
}
|
|
.dark .tiptap { color: #d1d5db; }
|
|
|
|
.tiptap h1 { font-size: 3.5rem; font-weight: 950; letter-spacing: -0.06em; margin-top: 3rem; margin-bottom: 1.5rem; color: #111827; line-height: 1.1; }
|
|
.tiptap h2 { font-size: 2.2rem; font-weight: 800; letter-spacing: -0.04em; margin-top: 2.5rem; margin-bottom: 1rem; color: #1f2937; line-height: 1.2; }
|
|
.tiptap h3 { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.02em; margin-top: 2rem; margin-bottom: 0.75rem; color: #374151; }
|
|
|
|
.dark .tiptap h1 { color: #f9fafb; }
|
|
.dark .tiptap h2 { color: #f3f4f6; }
|
|
.dark .tiptap h3 { color: #e5e7eb; }
|
|
|
|
.tiptap p { margin: 0.5rem 0; }
|
|
.tiptap ul { list-style-type: disc; padding-left: 1.5rem; margin: 1rem 0; }
|
|
.tiptap ol { list-style-type: decimal; padding-left: 1.5rem; margin: 1rem 0; }
|
|
.tiptap li { margin: 0.25rem 0; }
|
|
|
|
.tiptap pre {
|
|
background: #f8fafc;
|
|
border-radius: 1.25rem;
|
|
padding: 1.5rem;
|
|
font-family: 'JetBrains Mono', monospace;
|
|
font-size: 0.9rem;
|
|
border: 1px solid #e2e8f0;
|
|
margin: 2rem 0;
|
|
box-shadow: inset 0 2px 4px 0 rgba(0, 0, 0, 0.05);
|
|
}
|
|
.dark .tiptap pre { background: #0a0a0a; border-color: #1a1a1a; box-shadow: none; }
|
|
|
|
/* Efeito de Bloco Notion no Hover */
|
|
.tiptap > * {
|
|
position: relative;
|
|
transition: all 0.2s;
|
|
border-radius: 0.375rem;
|
|
}
|
|
.tiptap > *:hover::before {
|
|
content: ':::';
|
|
position: absolute;
|
|
left: -2rem;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
color: #d1d5db;
|
|
font-family: monospace;
|
|
font-weight: bold;
|
|
font-size: 1.25rem;
|
|
opacity: 0.5;
|
|
}
|
|
.dark .tiptap > *:hover::before { color: #334155; }
|
|
`}} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MenuButton({ onClick, active, icon, label }: { onClick: () => void, active: boolean, icon?: React.ReactNode, label?: string }) {
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
className={`p-2 rounded-lg transition-all flex items-center justify-center min-w-[32px] ${active ? 'bg-brand-500 text-white shadow-lg' : 'hover:bg-zinc-100 dark:hover:bg-zinc-800 text-zinc-500'}`}
|
|
>
|
|
{icon || <span className="text-xs font-black uppercase tracking-widest">{label}</span>}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function FloatingItem({ onClick, icon, title, desc }: { onClick: () => void, icon: React.ReactNode, title: string, desc: string }) {
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
className="flex items-center gap-4 p-3 hover:bg-zinc-50 dark:hover:bg-zinc-800 rounded-xl text-left transition-all group"
|
|
>
|
|
<div className="w-10 h-10 rounded-xl bg-white dark:bg-zinc-900 border border-zinc-100 dark:border-zinc-800 flex items-center justify-center shadow-sm group-hover:scale-110 transition-transform">
|
|
{icon}
|
|
</div>
|
|
<div>
|
|
<p className="text-xs font-black text-zinc-900 dark:text-white uppercase tracking-widest">{title}</p>
|
|
<p className="text-[10px] text-zinc-400 font-medium">{desc}</p>
|
|
</div>
|
|
</button>
|
|
);
|
|
}
|