gbclient/app/editor/page.tsx
Rodrigo Rodriguez (Pragmatismo) 21a8236516
Some checks failed
GBCI / build (push) Failing after 10m8s
Add global styles using Tailwind CSS with custom color variables for light and dark themes
2025-06-28 23:58:53 -03:00

508 lines
No EOL
18 KiB
TypeScript

"use client";
import History from '@tiptap/extension-history';
import Bold from '@tiptap/extension-bold'; // Import the extension
import Italic from '@tiptap/extension-italic'; // Import the extension
import { useState, useRef } from 'react';
import { useEditor, EditorContent, BubbleMenu, AnyExtension } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import TextStyle from '@tiptap/extension-text-style';
import FontFamily from '@tiptap/extension-font-family';
import Color from '@tiptap/extension-color';
import Highlight from '@tiptap/extension-highlight';
import TextAlign from '@tiptap/extension-text-align';
import Link from '@tiptap/extension-link';
import Image from '@tiptap/extension-image';
import Underline from '@tiptap/extension-underline';
import Table from '@tiptap/extension-table';
import TableCell from '@tiptap/extension-table-cell';
import TableHeader from '@tiptap/extension-table-header';
import TableRow from '@tiptap/extension-table-row';
import {
Underline as UnderlineIcon, AlignLeft, AlignCenter, AlignRight, AlignJustify,
Link as LinkIcon, Image as ImageIcon, Save, Table as TableIcon,
Type, Highlighter,
ChevronDown,
Undo, Redo, Copy,
ItalicIcon,
BoldIcon
} from 'lucide-react';
import './style.css';
// @ts-ignore
const RibbonTab = ({ label, isActive, onClick, children }) => (
<div className="ribbon-tab">
<button
onClick={onClick}
className={`ribbon-tab-button ${isActive ? 'active' : ''}`}
>
{label}
</button>
{isActive && (
<div className="ribbon-content">
{children}
</div>
)}
</div>
);
const RibbonGroup = ({ title, children }) => (
<div className="ribbon-group">
<div className="ribbon-group-content">
{children}
</div>
<div className="ribbon-group-title">{title}</div>
</div>
);
const RibbonButton = ({ icon: Icon, label, onClick, isActive, size = 'medium', dropdown = false }) => (
<button
onClick={onClick}
className={`ribbon-button ${size} ${isActive ? 'active' : ''}`}
title={label}
>
<div className="ribbon-button-content">
<Icon size={size === 'large' ? 24 : 16} />
{size === 'large' && <span className="ribbon-button-label">{label}</span>}
{dropdown && <ChevronDown size={12} className="dropdown-arrow" />}
</div>
</button>
);
export default function RibbonWordClone() {
const [fileName, setFileName] = useState('Document 1');
const [fontSize, setFontSize] = useState('12');
const [fontFamily, setFontFamily] = useState('Calibri');
const [textColor, setTextColor] = useState('#000000');
const [highlightColor, setHighlightColor] = useState('#ffff00');
const [activeTab, setActiveTab] = useState('home');
const [zoom, setZoom] = useState(100);
const [pages, setPages] = useState([1]);
const fileInputRef = useRef(null);
const editor = useEditor({
extensions: [
StarterKit.configure({ history: false }) as unknown as AnyExtension,
Bold,
Italic,
History,
TextStyle,
FontFamily,
Color,
Highlight.configure({ multicolor: true }),
TextAlign.configure({ types: ['heading', 'paragraph', 'tableCell'] }),
Link.configure({ openOnClick: false }),
Image,
Underline,
Table.configure({
resizable: true,
HTMLAttributes: {
class: 'editor-table',
},
}),
TableRow,
TableHeader,
TableCell,
],
content: `
<h1 style="text-align: center; font-size: 24px; margin-bottom: 20px;">${fileName}</h1>
<p><br></p>
<p>Start typing your document here...</p>
<p><br></p>
`,
// @ts-ignore
onUpdate: ({ editor }) => {
// Simulate multiple pages based on content height
const element = document.querySelector('.ProseMirror');
if (element) {
const contentHeight = element.scrollHeight;
const pageHeight = 1123; // A4 height in pixels at 96 DPI
const neededPages = Math.max(1, Math.ceil(contentHeight / pageHeight));
if (neededPages !== pages.length) {
setPages(Array.from({ length: neededPages }, (_, i) => i + 1));
}
}
},
});
const addImage = () => {
if (fileInputRef.current) {
fileInputRef.current.click();
}
};
const handleImageUpload = (e) => {
const file = e.target.files?.[0];
if (file && editor) {
const reader = new FileReader();
reader.onload = (event) => {
const imageUrl = event.target?.result;
// @ts-ignore
editor.chain().focus().setImage({ src: imageUrl }).run();
};
reader.readAsDataURL(file);
}
};
const saveDocument = () => {
if (editor) {
const content = editor.getHTML();
const blob = new Blob([content], { type: 'text/html' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${fileName}.html`;
a.click();
URL.revokeObjectURL(url);
}
};
if (!editor) {
return null;
}
return (
<div className="word-clone">
{/* Quick Access Toolbar */}
<div className="quick-access">
<button className="quick-access-btn" onClick={() => editor.chain().focus().undo().run()}>
<Undo size={14} />
</button>
<button className="quick-access-btn" onClick={() => editor.chain().focus().redo().run()}>
<Redo size={14} />
</button>
<div className="title-controls">
<input
type="text"
value={fileName}
onChange={(e) => setFileName(e.target.value)}
className="title-input"
placeholder="Document name"
/>
<button className="quick-access-btn" onClick={saveDocument}>
<Save size={14} />
</button>
</div>
</div>
{/* Ribbon */}
<div className="ribbon">
<div className="ribbon-tabs">
<button
className={`ribbon-tab-button ${activeTab === 'home' ? 'active' : ''}`}
onClick={() => setActiveTab('home')}
>
Home
</button>
<button
className={`ribbon-tab-button ${activeTab === 'insert' ? 'active' : ''}`}
onClick={() => setActiveTab('insert')}
>
Insert
</button>
<button
className={`ribbon-tab-button ${activeTab === 'layout' ? 'active' : ''}`}
onClick={() => setActiveTab('layout')}
>
Layout
</button>
<button
className={`ribbon-tab-button ${activeTab === 'view' ? 'active' : ''}`}
onClick={() => setActiveTab('view')}
>
View
</button>
</div>
{activeTab === 'home' && (
<div className="ribbon-content">
<RibbonGroup title="Clipboard">
<RibbonButton
icon={Copy}
label="Copy"
size="large"
onClick={() => document.execCommand('copy')} isActive={undefined} />
<RibbonButton
icon={Copy}
label="Paste"
size="large"
onClick={() => document.execCommand('paste')} isActive={undefined} />
<RibbonButton
icon={Copy}
label="Cut"
size="medium"
onClick={() => document.execCommand('cut')} isActive={undefined} />
</RibbonGroup>
<RibbonGroup title="Font">
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<div style={{ display: 'flex', gap: '4px' }}>
<select
value={fontFamily}
onChange={(e) => {
setFontFamily(e.target.value);
editor.chain().focus().setFontFamily(e.target.value).run();
}}
className="format-select"
style={{ width: '120px' }}
>
<option value="Calibri">Calibri</option>
<option value="Arial">Arial</option>
<option value="Times New Roman">Times New Roman</option>
<option value="Georgia">Georgia</option>
<option value="Verdana">Verdana</option>
</select>
<select
value={fontSize}
onChange={(e) => {
setFontSize(e.target.value);
// editor.chain().focus().setFontSize(e.target.value + 'pt').run();
}}
className="format-select"
style={{ width: '60px' }}
>
<option value="8">8</option>
<option value="9">9</option>
<option value="10">10</option>
<option value="11">11</option>
<option value="12">12</option>
<option value="14">14</option>
<option value="16">16</option>
<option value="18">18</option>
<option value="20">20</option>
<option value="24">24</option>
<option value="28">28</option>
<option value="36">36</option>
</select>
</div>
<div style={{ display: 'flex', gap: '2px' }}>
<RibbonButton
icon={BoldIcon}
label="Bold"
onClick={() => editor.chain().focus().toggleBold().run()}
isActive={editor.isActive('bold')}
/>
<RibbonButton
icon={ItalicIcon}
label="Italic"
onClick={() => editor.chain().focus().toggleItalic().run()}
isActive={editor.isActive('italic')}
/>
<RibbonButton
icon={UnderlineIcon}
label="Underline"
onClick={() => editor.chain().focus().toggleUnderline().run()}
isActive={editor.isActive('underline')}
/>
<div className="color-picker-wrapper">
<RibbonButton icon={Type} label="Text Color" onClick={undefined} isActive={undefined} />
<input
type="color"
value={textColor}
onChange={(e) => {
setTextColor(e.target.value);
editor.chain().focus().setColor(e.target.value).run();
}}
className="color-picker"
/>
<div
className="color-indicator"
style={{ backgroundColor: textColor }}
/>
</div>
<div className="color-picker-wrapper">
<RibbonButton icon={Highlighter} label="Highlight" onClick={undefined} isActive={undefined} />
<input
type="color"
value={highlightColor}
onChange={(e) => {
setHighlightColor(e.target.value);
editor.chain().focus().setHighlight({ color: e.target.value }).run();
}}
className="color-picker"
/>
<div
className="color-indicator"
style={{ backgroundColor: highlightColor }}
/>
</div>
</div>
</div>
</RibbonGroup>
<RibbonGroup title="Paragraph">
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<div style={{ display: 'flex', gap: '2px' }}>
<RibbonButton
icon={AlignLeft}
label="Align Left"
onClick={() => editor.chain().focus().setTextAlign('left').run()}
isActive={editor.isActive({ textAlign: 'left' })}
/>
<RibbonButton
icon={AlignCenter}
label="Center"
onClick={() => editor.chain().focus().setTextAlign('center').run()}
isActive={editor.isActive({ textAlign: 'center' })}
/>
<RibbonButton
icon={AlignRight}
label="Align Right"
onClick={() => editor.chain().focus().setTextAlign('right').run()}
isActive={editor.isActive({ textAlign: 'right' })}
/>
<RibbonButton
icon={AlignJustify}
label="Justify"
onClick={() => editor.chain().focus().setTextAlign('justify').run()}
isActive={editor.isActive({ textAlign: 'justify' })}
/>
</div>
</div>
</RibbonGroup>
</div>
)}
{activeTab === 'insert' && (
<div className="ribbon-content">
<RibbonGroup title="Illustrations">
<RibbonButton
icon={ImageIcon}
label="Picture"
size="large"
onClick={addImage} isActive={undefined} />
</RibbonGroup>
<RibbonGroup title="Tables">
<RibbonButton
icon={TableIcon}
label="Table"
size="large"
onClick={() => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()} isActive={undefined} />
</RibbonGroup>
<RibbonGroup title="Links">
<RibbonButton
icon={LinkIcon}
label="Link"
size="large"
onClick={() => {
const previousUrl = editor.getAttributes('link').href;
const url = window.prompt('URL', previousUrl);
if (url === null) return;
if (url === '') {
editor.chain().focus().extendMarkRange('link').unsetLink().run();
return;
}
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
}}
isActive={editor.isActive('link')}
/>
</RibbonGroup>
</div>
)}
{activeTab === 'view' && (
<div className="ribbon-content">
<RibbonGroup title="Zoom">
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' }}>
<div style={{ fontSize: '14px', fontWeight: '600' }}>{zoom}%</div>
<input
type="range"
min="50"
max="200"
value={zoom}
onChange={(e) => setZoom(parseInt(e.target.value))}
className="zoom-slider"
/>
</div>
</RibbonGroup>
</div>
)}
</div>
{/* Editor Area */}
<div className="editor-container">
<div className="editor-main">
<div className="pages-container">
{pages.map((pageNum, index) => (
<div key={pageNum} className="page">
<div className="page-number">Page {pageNum}</div>
<div className="page-content">
{index === 0 && <EditorContent editor={editor} />}
</div>
</div>
))}
</div>
</div>
</div>
{/* Bubble Menu */}
{editor && (
<BubbleMenu editor={editor}>
<div className="bubble-menu">
<RibbonButton
icon={BoldIcon}
label="Bold"
onClick={() => editor.chain().focus().toggleBold().run()}
isActive={editor.isActive('bold')}
/>
<RibbonButton
icon={ItalicIcon}
label="Italic"
onClick={() => editor.chain().focus().toggleItalic().run()}
isActive={editor.isActive('italic')}
/>
<RibbonButton
icon={UnderlineIcon}
label="Underline"
onClick={() => editor.chain().focus().toggleUnderline().run()}
isActive={editor.isActive('underline')}
/>
<RibbonButton
icon={LinkIcon}
label="Link"
onClick={() => {
const previousUrl = editor.getAttributes('link').href;
const url = window.prompt('URL', previousUrl);
if (url === null) return;
if (url === '') {
editor.chain().focus().extendMarkRange('link').unsetLink().run();
return;
}
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
}}
isActive={editor.isActive('link')}
/>
</div>
</BubbleMenu>
)}
{/* Status Bar */}
<div className="status-bar">
<div>
Page {pages.length} of {pages.length} | Words: {editor.storage.characterCount?.words() || 0}
</div>
<div className="zoom-controls">
<button onClick={() => setZoom(Math.max(50, zoom - 10))}>-</button>
<span>{zoom}%</span>
<button onClick={() => setZoom(Math.min(200, zoom + 10))}>+</button>
</div>
</div>
{/* Hidden file input */}
<input
type="file"
ref={fileInputRef}
onChange={handleImageUpload}
accept="image/*"
style={{ display: 'none' }}
/>
</div>
);
}