gbclient/app/editor/page.tsx

917 lines
28 KiB
TypeScript
Raw Normal View History

"use client";
import { useState, useRef, useEffect } from 'react';
import { useEditor, EditorContent, BubbleMenu } 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 {
Bold, Italic, Underline as UnderlineIcon, AlignLeft, AlignCenter, AlignRight, AlignJustify,
Link as LinkIcon, Image as ImageIcon, Save, FileText, Printer, Table as TableIcon,
Plus, Minus, Trash2, Type, Palette, Highlighter, FileImage, File, Home,
View, ChevronDown, Search,
Undo, Redo, Copy, MoreVertical
} from 'lucide-react';
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,
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>
`,
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;
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">
<style jsx global>{`
:root {
/* 3DBevel Theme */
--background: 0 0% 80%;
--foreground: 0 0% 10%;
--card: 0 0% 75%;
--card-foreground: 0 0% 10%;
--popover: 0 0% 80%;
--popover-foreground: 0 0% 10%;
--primary: 210 80% 40%;
--primary-foreground: 0 0% 80%;
--secondary: 0 0% 70%;
--secondary-foreground: 0 0% 10%;
--muted: 0 0% 65%;
--muted-foreground: 0 0% 30%;
--accent: 30 80% 40%;
--accent-foreground: 0 0% 80%;
--destructive: 0 85% 60%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 70%;
--input: 0 0% 70%;
--ring: 210 80% 40%;
--radius: 0.5rem;
}
* {
box-sizing: border-box;
}
.word-clone {
min-height: 100vh;
background: hsl(var(--background));
color: hsl(var(--foreground));
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
/* Title Bar */
.title-bar {
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
padding: 8px 16px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 2px solid hsl(var(--border));
}
.title-bar h1 {
font-size: 14px;
font-weight: 600;
margin: 0;
}
.title-controls {
display: flex;
gap: 8px;
}
.title-input {
background: hsl(var(--input));
border: 1px solid hsl(var(--border));
border-radius: var(--radius);
padding: 4px 8px;
font-size: 12px;
color: hsl(var(--foreground));
}
/* Quick Access Toolbar */
.quick-access {
background: hsl(var(--card));
border-bottom: 1px solid hsl(var(--border));
padding: 4px 8px;
display: flex;
align-items: center;
gap: 2px;
}
.quick-access-btn {
background: transparent;
border: 1px solid transparent;
border-radius: 3px;
padding: 4px;
cursor: pointer;
color: hsl(var(--foreground));
transition: all 0.2s;
}
.quick-access-btn:hover {
background: hsl(var(--muted));
border-color: hsl(var(--border));
}
/* Ribbon */
.ribbon {
background: hsl(var(--card));
border-bottom: 2px solid hsl(var(--border));
}
.ribbon-tabs {
display: flex;
background: hsl(var(--muted));
border-bottom: 1px solid hsl(var(--border));
}
.ribbon-tab-button {
background: transparent;
border: none;
padding: 8px 16px;
cursor: pointer;
font-size: 12px;
color: hsl(var(--muted-foreground));
border-bottom: 2px solid transparent;
transition: all 0.2s;
}
.ribbon-tab-button:hover {
background: hsl(var(--secondary));
color: hsl(var(--foreground));
}
.ribbon-tab-button.active {
background: hsl(var(--card));
color: hsl(var(--foreground));
border-bottom-color: hsl(var(--primary));
font-weight: 600;
}
.ribbon-content {
display: flex;
padding: 8px;
gap: 2px;
min-height: 80px;
align-items: stretch;
}
.ribbon-group {
display: flex;
flex-direction: column;
border-right: 1px solid hsl(var(--border));
padding-right: 8px;
margin-right: 8px;
}
.ribbon-group:last-child {
border-right: none;
}
.ribbon-group-content {
display: flex;
flex-wrap: wrap;
gap: 2px;
flex: 1;
align-items: flex-start;
padding: 4px 0;
}
.ribbon-group-title {
font-size: 10px;
color: hsl(var(--muted-foreground));
text-align: center;
margin-top: 4px;
border-top: 1px solid hsl(var(--border));
padding-top: 2px;
}
.ribbon-button {
background: transparent;
border: 1px solid transparent;
border-radius: 3px;
cursor: pointer;
color: hsl(var(--foreground));
transition: all 0.2s;
position: relative;
}
.ribbon-button:hover {
background: hsl(var(--muted));
border-color: hsl(var(--border));
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.ribbon-button.active {
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border-color: hsl(var(--primary));
}
.ribbon-button.medium {
padding: 6px;
min-width: 32px;
min-height: 32px;
}
.ribbon-button.large {
padding: 8px;
min-width: 48px;
min-height: 48px;
flex-direction: column;
}
.ribbon-button-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.ribbon-button-label {
font-size: 10px;
text-align: center;
line-height: 1.1;
}
.dropdown-arrow {
position: absolute;
bottom: 2px;
right: 2px;
}
/* Format Controls */
.format-select {
background: hsl(var(--input));
border: 1px solid hsl(var(--border));
border-radius: 3px;
padding: 4px 6px;
font-size: 11px;
color: hsl(var(--foreground));
margin: 2px;
}
.color-picker-wrapper {
position: relative;
display: inline-block;
}
.color-picker {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
.color-indicator {
position: absolute;
bottom: 2px;
left: 50%;
transform: translateX(-50%);
width: 16px;
height: 3px;
border-radius: 1px;
}
/* Editor Area */
.editor-container {
display: flex;
flex: 1;
background: hsl(var(--muted));
}
.editor-sidebar {
width: 200px;
background: hsl(var(--card));
border-right: 1px solid hsl(var(--border));
padding: 16px;
}
.editor-main {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
overflow-y: auto;
max-height: calc(100vh - 200px);
}
.pages-container {
display: flex;
flex-direction: column;
gap: 20px;
transform: scale(${zoom / 100});
transform-origin: top center;
}
.page {
width: 210mm;
min-height: 297mm;
background: white;
box-shadow:
0 0 0 1px hsl(var(--border)),
0 4px 8px rgba(0,0,0,0.1),
0 8px 16px rgba(0,0,0,0.05);
position: relative;
margin: 0 auto;
}
.page-number {
position: absolute;
top: -30px;
left: 50%;
transform: translateX(-50%);
font-size: 12px;
color: hsl(var(--muted-foreground));
background: hsl(var(--background));
padding: 2px 8px;
border-radius: 10px;
}
.page-content {
padding: 25mm;
min-height: 247mm;
}
.ProseMirror {
outline: none;
min-height: 100%;
}
.ProseMirror img {
max-width: 100%;
height: auto;
border-radius: 4px;
}
.ProseMirror a {
color: hsl(var(--primary));
text-decoration: underline;
}
/* Table styles */
.editor-table {
border-collapse: collapse;
margin: 16px 0;
width: 100%;
border: 1px solid hsl(var(--border));
}
.editor-table td,
.editor-table th {
border: 1px solid hsl(var(--border));
padding: 8px 12px;
min-width: 50px;
position: relative;
}
.editor-table th {
background: hsl(var(--muted));
font-weight: 600;
}
/* Bubble Menu */
.bubble-menu {
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: var(--radius);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
display: flex;
padding: 4px;
gap: 2px;
}
.bubble-menu .ribbon-button {
min-width: 28px;
min-height: 28px;
padding: 4px;
}
/* Status Bar */
.status-bar {
background: hsl(var(--card));
border-top: 1px solid hsl(var(--border));
padding: 4px 12px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 11px;
color: hsl(var(--muted-foreground));
}
.zoom-controls {
display: flex;
align-items: center;
gap: 8px;
}
.zoom-slider {
width: 100px;
}
@media print {
.title-bar,
.quick-access,
.ribbon,
.editor-sidebar,
.status-bar {
display: none !important;
}
.editor-main {
padding: 0;
max-height: none;
}
.pages-container {
transform: none;
gap: 0;
}
.page {
box-shadow: none;
margin: 0;
break-after: page;
}
.page-number {
display: none;
}
}
`}</style>
{/* 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>
<button className="quick-access-btn" onClick={saveDocument}>
<Save 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={Bold}
label="Bold"
onClick={() => editor.chain().focus().toggleBold().run()}
isActive={editor.isActive('bold')}
/>
<RibbonButton
icon={Italic}
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={Bold}
label="Bold"
onClick={() => editor.chain().focus().toggleBold().run()}
isActive={editor.isActive('bold')}
/>
<RibbonButton
icon={Italic}
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>
);
}