feat: add imageSignature

master
shahid 2024-11-28 12:05:05 +05:30
parent f9c105c95d
commit 389e4f6e3a
25 changed files with 7782 additions and 212 deletions

6906
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,18 +9,43 @@
"lint": "next lint"
},
"dependencies": {
"react": "19.0.0-rc-66855b96-20241106",
"react-dom": "19.0.0-rc-66855b96-20241106",
"next": "15.0.3"
"@aws-sdk/client-s3": "^3.691.0",
"@aws-sdk/s3-request-presigner": "^3.691.0",
"@prosekit/react": "^0.4.4",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-slot": "^1.1.0",
"@types/formidable": "^3.4.5",
"aws-sdk": "^2.1692.0",
"axios": "^1.7.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"express": "^4.21.1",
"formidable": "^3.5.2",
"lucide-react": "^0.461.0",
"multer": "^1.4.5-lts.1",
"next": "15.0.3",
"prosekit": "^0.10.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-draggable": "^4.4.6",
"react-modal": "^3.16.1",
"react-resizable": "^3.0.5",
"tailwind-merge": "^2.5.5",
"uuid": "^11.0.3"
},
"devDependencies": {
"typescript": "^5",
"@types/aws-sdk": "^0.0.42",
"@types/express": "^5.0.0",
"@types/multer": "^1.4.12",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/react-modal": "^3.16.3",
"eslint": "^8",
"eslint-config-next": "15.0.3",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"eslint": "^8",
"eslint-config-next": "15.0.3"
"tailwindcss-animate": "^1.0.7",
"typescript": "^5"
}
}

BIN
public/signatures/111.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

BIN
public/signatures/4153.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 898 KiB

View File

@ -0,0 +1,17 @@
// extension.ts
import { defineBasicExtension } from 'prosekit/basic';
import { union } from 'prosekit/core';
import { defineImage } from './image'; // Assuming this already exists
import { definesignature } from './signature/signature'; // Import the signature node
import { definePlaceholder } from './signature/signature-spec'; // Import the placeholder node
export function defineExtension() {
return union([
defineBasicExtension(), // Adds basic functionality (e.g., text, paragraphs)
defineImage(), // Adds image handling
definesignature(), // Adds the signature node
definePlaceholder(), // Adds the placeholder node
]);
}
export type EditorExtension = ReturnType<typeof defineExtension>;

View File

@ -0,0 +1,18 @@
import { Extension, defineCommands,insertNode } from "prosekit/core";
import { ImageAttrs } from "./image-spec";
export type ImageCommandsExtension = Extension <{
Commands: {
insertImage:[attrs?: ImageAttrs]
};
}>;
export function defineImageCommands(): ImageCommandsExtension{
return defineCommands({
insertImage:(attrs?: ImageAttrs) =>{
return insertNode ({type:'image' , attrs})
}
})
}

View File

@ -0,0 +1,75 @@
import { type Extension, defineNodeSpec } from "prosekit/core";
// Define your custom ImageAttrs interface here, including `alt`
export interface ImageAttrs {
src?: string | null;
alt?: string | null;
width?: number | null;
height?: number | null;
}
export type ImageSpecExtension = Extension<{
Nodes: {
image: ImageAttrs;
};
}>;
export function defineImageSpec(): ImageSpecExtension {
return defineNodeSpec({
name: 'image',
content: '',
inline:false,
attrs: {
src: { default: null },
alt: { default: '' },
width: { default: null },
height: { default: null },
},
group: 'block',
defining: true,
draggable: true,
parseDOM: [
{
tag: 'img[src]',
getAttrs: (element): ImageAttrs => {
if (!(element instanceof HTMLElement)){
return {src: null , alt: null}
}
const src = element.getAttribute('src') || null;
const alt = element.getAttribute('alt') || '';
let width: number | null = null;
let height: number | null = null;
// Optionally get width and height from DOM
const rect = element.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
width = rect.width;
height = rect.height;
} else if (
element instanceof HTMLImageElement &&
element.naturalWidth > 0 &&
element.naturalHeight > 0
) {
width = element.naturalWidth;
height = element.naturalHeight;
}
return { src, alt, width, height };
},
},
],
toDOM(node) {
const {src , alt , width , height} = node.attrs as ImageAttrs
const attrs : Record<string, string> = {src: src || ''}
if(alt) attrs.alt = alt
if (width) attrs.width = width.toString()
if(height) attrs.height = height.toString()
return ['img', attrs] ;
},
});
}

View File

@ -0,0 +1,13 @@
import {union, type Union} from '@prosekit/core'
import { defineImageSpec, type ImageSpecExtension } from './image-spec';
import { defineImageCommands, type ImageCommandsExtension } from './image-commands';
export type ImageExtension = Union <[ImageSpecExtension,ImageCommandsExtension]>;
export function defineImage (): ImageExtension{
return union ([
defineImageSpec(),
defineImageCommands()
])
}

View File

@ -0,0 +1,35 @@
import { defineNodeSpec } from 'prosekit/core';
export function definePlaceholder() {
return defineNodeSpec({
name: 'placeholder',
group: 'block',
content: 'inline*', // Allows inline content
attrs: {
id: { default: null }, // Unique identifier
width: { default: 200 },
height: { default: 100 },
},
parseDOM: [
{
tag: 'div[data-placeholder]',
getAttrs: (dom) => ({
id: dom.getAttribute('data-id'),
width: parseInt(dom.style.width) || 200,
height: parseInt(dom.style.height) || 100,
}),
},
],
toDOM(node) {
return [
'div',
{
'data-placeholder': '',
'data-id': node.attrs.id,
style: `border: 1px dashed gray; padding: 10px; width: ${node.attrs.width}px; height: ${node.attrs.height}px;`,
},
0, // Indicates the node can have children
];
},
});
}

View File

@ -0,0 +1,39 @@
import { DraggableSignature } from "@/components/ui/dragSize";
import { defineNodeSpec } from "prosekit/core";
export function definesignature(){
return defineNodeSpec({
name:'signature',
inline:true,
draggable:true,
attrs:{
src: { default: null }, // URL of the signature image
width: { default: 100 },
height: { default: 50 },
},
parseDOM:[
{
tag: 'img[data-signature]',
getAttrs:(dom:HTMLElement) =>({
src: dom.getAttribute('src'),
width: dom.getAttribute('width'),
height: dom.getAttribute('height'),
}),
},
],
toDOM(node) {
const {src,width,height} = node.attrs;
return[
'img',
{
'data-signature': '',
src,
width,
height,
style: `cursor: move; width: ${width}px; height: ${height}px;`,
},
];
},
});
}

View File

@ -0,0 +1,195 @@
import { useEditor } from 'prosekit/react';
import { PopoverContent, PopoverRoot, PopoverTrigger } from 'prosekit/react/popover';
import { useEffect, useState, FC, ReactNode } from 'react';
import Button from './ui/button';
import type { EditorExtension } from '../components/extension/extension';
// Image handling service with direct URL storage
const imageService = {
// Upload image and return direct URL
uploadImage: async (file: File): Promise<string> => {
try {
// Create FormData for file upload
const formData = new FormData();
formData.append('image', file);
// Simulate API call - replace with your actual API endpoint
await new Promise(resolve => setTimeout(resolve, 500));
// Return a direct URL that doesn't require signing
return `/uploads/${file.name}`;
} catch (error) {
console.error('Upload failed:', error);
throw error;
}
},
// Store image metadata in local storage
storeImageMetadata: (imageUrl: string, metadata: ImageMetadata) => {
localStorage.setItem(`image-metadata-${imageUrl}`, JSON.stringify(metadata));
},
// Retrieve image metadata from local storage
getImageMetadata: (imageUrl: string): ImageMetadata | null => {
const stored = localStorage.getItem(`image-metadata-${imageUrl}`);
return stored ? JSON.parse(stored) : null;
}
};
interface ImageMetadata {
url: string;
originalName: string;
uploadedAt: number;
size?: number;
}
const ImageComponent: FC<{ url: string }> = ({ url }) => {
const [metadata, setMetadata] = useState<ImageMetadata | null>(() => {
return imageService.getImageMetadata(url);
});
useEffect(() => {
if (!metadata) {
// If metadata doesn't exist, create and store it
const newMetadata: ImageMetadata = {
url,
originalName: url.split('/').pop() || '',
uploadedAt: Date.now()
};
imageService.storeImageMetadata(url, newMetadata);
setMetadata(newMetadata);
}
}, [url, metadata]);
return (
<img
src={url}
alt={metadata?.originalName || 'Uploaded image'}
className="max-w-full h-auto"
/>
);
};
export const ImageUploadPopover: FC<{
tooltip: string;
disabled: boolean;
children: ReactNode;
}> = ({ tooltip, disabled, children }) => {
const [open, setOpen] = useState(false);
const [webUrl, setWebUrl] = useState('');
const [isUploading, setIsUploading] = useState(false);
const [uploadedUrl, setUploadedUrl] = useState<string>('');
const editor = useEditor<EditorExtension>();
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
setIsUploading(true);
try {
const url = await imageService.uploadImage(file);
// Store metadata
const metadata: ImageMetadata = {
url,
originalName: file.name,
uploadedAt: Date.now(),
size: file.size
};
imageService.storeImageMetadata(url, metadata);
setUploadedUrl(url);
} catch (error) {
console.error('Upload failed:', error);
} finally {
setIsUploading(false);
}
};
const handleWebUrlChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setWebUrl(event.target.value);
};
const handleSubmit = () => {
if (!editor) return;
const imageUrl = uploadedUrl || webUrl;
if (imageUrl) {
editor.commands.insertImage({
src: imageUrl,
alt: imageService.getImageMetadata(imageUrl)?.originalName || 'image',
});
}
setOpen(false);
setWebUrl('');
setUploadedUrl('');
};
const handleOpenChange = (open: boolean) => {
if (!open) {
setTimeout(() => {
setWebUrl('');
setUploadedUrl('');
}, 300);
}
setOpen(open);
};
return (
<PopoverRoot open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger>
<Button pressed={open} disabled={disabled} tooltip={tooltip}>
{children}
</Button>
</PopoverTrigger>
<PopoverContent className="flex flex-col gap-y-4 p-6 text-sm w-sm z-10 box-border rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-neutral-900 shadow-lg">
{!uploadedUrl && (
<>
<label>Embed Link</label>
<input
className="input-style"
placeholder="Paste the image link..."
type="url"
value={webUrl}
onChange={handleWebUrlChange}
/>
</>
)}
{!webUrl && (
<>
<label>Upload</label>
<input
className="input-style"
accept="image/*"
type="file"
onChange={handleFileChange}
disabled={isUploading}
/>
</>
)}
{(webUrl || uploadedUrl) && (
<div className="flex flex-col gap-2">
<div className="preview-container">
<img
src={uploadedUrl || webUrl}
alt="Preview"
className="max-w-full h-auto"
/>
</div>
<button
onClick={handleSubmit}
disabled={(!uploadedUrl && !webUrl) || isUploading}
className="button-style"
>
{isUploading ? 'Uploading...' : 'Insert Image'}
</button>
</div>
)}
</PopoverContent>
</PopoverRoot>
);
};

View File

@ -0,0 +1,44 @@
import {
TooltipContent,
TooltipRoot,
TooltipTrigger,
} from 'prosekit/react/tooltip';
import type { ReactNode } from 'react';
interface ButtonProps {
pressed?: boolean;
disabled?: boolean;
onClick?: VoidFunction;
tooltip?: string;
children: ReactNode;
}
export default function Button({
pressed,
disabled,
onClick,
tooltip,
children,
}: ButtonProps) {
return (
<TooltipRoot>
<TooltipTrigger className="block">
<button
data-state={pressed ? 'on' : 'off'}
disabled={disabled}
onClick={() => onClick?.()}
onMouseDown={(event) => event.preventDefault()}
className="button-styles"
>
{children}
{tooltip && <span className="sr-only">{tooltip}</span>}
</button>
</TooltipTrigger>
{tooltip && (
<TooltipContent className="tooltip-styles">
{tooltip}
</TooltipContent>
)}
</TooltipRoot>
);
}

View File

@ -0,0 +1,97 @@
import React, { useState } from 'react';
import Draggable from 'react-draggable';
interface DraggableSignatureProps {
src: string;
onClose: () => void; // Add onClose as a prop
}
export const DraggableSignature: React.FC<DraggableSignatureProps> = ({ src, onClose }) => {
const [size, setSize] = useState({ width: 100, height: 50 });
const [isResizing, setIsResizing] = useState(false);
const [startPosition, setStartPosition] = useState({ x: 0, y: 0 });
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault();
setIsResizing(true);
setStartPosition({ x: e.clientX, y: e.clientY });
};
const handleMouseMove = (e: MouseEvent) => {
if (isResizing) {
const dx = e.clientX - startPosition.x;
const dy = e.clientY - startPosition.y;
setSize((prevSize) => ({
width: Math.max(50, prevSize.width + dx),
height: Math.max(25, prevSize.height + dy),
}));
setStartPosition({ x: e.clientX, y: e.clientY });
}
};
const handleMouseUp = () => {
setIsResizing(false);
};
React.useEffect(() => {
if (isResizing) {
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
} else {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
}
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, [isResizing]);
return (
<Draggable>
<div
style={{
position: 'relative',
display: 'inline-block',
width: size.width,
height: size.height,
border: '1px solid #ccc',
}}
>
<img src={src} alt="Signature" style={{ width: '100%', height: '100%' }} />
<div
style={{
position: 'absolute',
bottom: 0,
right: 0,
background: 'rgba(0, 0, 0, 0.5)',
cursor: 'se-resize',
width: '15px',
height: '15px',
}}
onMouseDown={handleMouseDown}
></div>
{/* Close Button */}
<button
onClick={onClose}
style={{
position: 'absolute',
top: 0,
right: 0,
background: 'red',
color: 'white',
border: 'none',
borderRadius: '50%',
cursor: 'pointer',
width: '20px',
height: '20px',
}}
>
X
</button>
</div>
</Draggable>
);
};

View File

@ -0,0 +1,156 @@
import 'prosekit/basic/style.css';
import { createEditor } from 'prosekit/core';
import { ProseKit } from 'prosekit/react';
import { useEffect, useMemo, useState } from 'react';
import Draggable from 'react-draggable';
import Toolbar from '../ui/toolbars';
import Button from './button';
import { defineExtension } from '../extension/extension';
import { SignatureModal } from './signatureModal';
export default function Editor() {
const [selectedSignature, setSelectedSignature] = useState<string | null>(null);
const [isModalOpen, setModalOpen] = useState(false);
const [placeholderSize, setPlaceholderSize] = useState({ width: 200, height: 100 });
// Initialize editor with extensions
const editor = useMemo(() => {
const extension = defineExtension();
return createEditor({ extension });
}, []);
// Load saved content on editor mount
useEffect(() => {
const savedContent = localStorage.getItem('editorContent');
if (savedContent) {
editor.state.doc = editor.schema.nodeFromJSON(JSON.parse(savedContent));
}
}, [editor]);
// Save editor content to localStorage
const handleSave = () => {
const content = editor.state.doc.toJSON();
localStorage.setItem('editorContent', JSON.stringify(content));
alert('Content saved');
};
// Handle placeholder resizing and image resizing together
const handleResize = (e: React.MouseEvent) => {
const startX = e.clientX;
const startY = e.clientY;
const onMouseMove = (event: MouseEvent) => {
const dx = event.clientX - startX;
const dy = event.clientY - startY;
setPlaceholderSize((prevSize) => ({
width: Math.max(100, prevSize.width + dx),
height: Math.max(50, prevSize.height + dy),
}));
};
const onMouseUp = () => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
};
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
};
// Handle signature selection and render inside the placeholder
const handleSignatureSelect = (src: string) => {
setSelectedSignature(src);
setModalOpen(false);
};
// Handle image removal
const handleRemoveImage = () => {
setSelectedSignature(null);
};
return (
<ProseKit editor={editor}>
<div className="editor-container">
<Toolbar />
<Button onClick={handleSave} tooltip="Save">
Save
</Button>
{/* Draggable Placeholder */}
<Draggable>
<div
className="placeholder-container"
style={{
border: '1px dashed gray',
width: `${placeholderSize.width}px`,
height: `${placeholderSize.height}px`,
cursor: 'move',
position: 'relative',
marginBottom: '20px',
}}
onClick={() => !selectedSignature && setModalOpen(true)}
>
{!selectedSignature && <span>Click to add a signature</span>}
{selectedSignature && (
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
{/* Image */}
<img
src={selectedSignature}
alt="Signature"
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
}}
/>
{/* Close Button */}
<button
onClick={handleRemoveImage}
style={{
position: 'absolute',
top: '5px',
right: '5px',
background: 'rgba(255, 255, 255, 0.8)',
border: 'none',
borderRadius: '50%',
width: '20px',
height: '20px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
boxShadow: '0px 2px 4px rgba(0, 0, 0, 0.2)',
}}
>
×
</button>
</div>
)}
{/* Resize Handle */}
<div
className="resize-handle"
onMouseDown={handleResize}
style={{
position: 'absolute',
bottom: 0,
right: 0,
width: '15px',
height: '15px',
background: 'rgba(0, 0, 0, 0.5)',
cursor: 'se-resize',
}}
/>
</div>
</Draggable>
{/* Signature Modal */}
{isModalOpen && <SignatureModal onSelect={handleSignatureSelect} />}
<div ref={editor.mount} className="editor-content"></div>
</div>
</ProseKit>
);
}

View File

@ -0,0 +1,37 @@
import React, { useState } from 'react';
import Modal from 'react-modal';
export const SignatureModal: React.FC<{ onSelect: (src: string) => void }> = ({ onSelect }) => {
const [isOpen, setIsOpen] = useState(false);
const sampleSignatures = [
'signatures/111.jpg',
'signatures/4153.jpg',
'signatures/111.jpg',
];
const handleSelect = (src: string) => {
onSelect(src);
setIsOpen(false);
};
return (
<>
<button onClick={() => setIsOpen(true)}>Add Signature</button>
<Modal isOpen={isOpen} onRequestClose={() => setIsOpen(false)} contentLabel="Select Signature">
<h2>Select a Signature</h2>
<div style={{ display: 'flex', gap: '10px' }}>
{sampleSignatures.map((src) => (
<img
key={src}
src={src}
alt="Signature"
style={{ cursor: 'pointer', width: '100px' }}
onClick={() => handleSelect(src)}
/>
))}
</div>
</Modal>
</>
);
};

View File

@ -0,0 +1,83 @@
import { useEditor } from 'prosekit/react'
import Button from './button'
import type { EditorExtension } from '../extension/extension'
import { ImageUploadPopover} from '../image-Upload'
export default function Toolbar() {
const editor = useEditor<EditorExtension>({ update: true })
return (
<div className='z-2 box-border border-zinc-200 dark:border-zinc-800 border-solid border-l-0 border-r-0 border-t-0 border-b flex flex-wrap gap-1 p-2 items-center'>
<Button
pressed={false}
disabled={!editor.commands.undo.canExec()}
onClick={editor.commands.undo}
tooltip="Undo"
>
<div className='i-lucide-undo-2 h-5 w-5' />
</Button>
<Button
pressed={false}
disabled={!editor.commands.redo.canExec()}
onClick={editor.commands.redo}
tooltip="Redo"
>
<div className='i-lucide-redo-2 h-5 w-5' />
</Button>
<Button
pressed={editor.marks.bold.isActive()}
disabled={!editor.commands.toggleBold.canExec()}
onClick={editor.commands.toggleBold}
tooltip="Bold"
>
<div className='i-lucide-bold h-5 w-5' />
</Button>
<Button
pressed={editor.marks.italic.isActive()}
disabled={!editor.commands.toggleItalic.canExec()}
onClick={editor.commands.toggleItalic}
tooltip="Italic"
>
<div className='i-lucide-italic h-5 w-5' />
</Button>
<Button
pressed={editor.nodes.heading.isActive({ level: 1 })}
disabled={!editor.commands.toggleHeading.canExec({ level: 1 })}
onClick={() => editor.commands.toggleHeading({ level: 1 })}
tooltip="Heading 1"
>
<div className='i-lucide-heading-1 h-5 w-5' />
</Button>
<Button
pressed={editor.nodes.heading.isActive({ level: 2 })}
disabled={!editor.commands.toggleHeading.canExec({ level: 2 })}
onClick={() => editor.commands.toggleHeading({ level: 2 })}
tooltip="Heading 2"
>
<div className='i-lucide-heading-2 h-5 w-5' />
</Button>
<Button
pressed={editor.nodes.heading.isActive({ level: 3 })}
disabled={!editor.commands.toggleHeading.canExec({ level: 3 })}
onClick={() => editor.commands.toggleHeading({ level: 3 })}
tooltip="Heading 3"
>
<div className='i-lucide-heading-3 h-5 w-5' />
</Button>
<ImageUploadPopover
disabled={!editor.commands.insertImage.canExec()}
tooltip="Insert Image"
>
<div className='i-lucide-image h-5 w-5 ' />
</ImageUploadPopover>
</div>
)
}

View File

@ -3,4 +3,4 @@ import type { AppProps } from "next/app";
export default function App({ Component, pageProps }: AppProps) {
return <Component {...pageProps} />;
}
}

View File

@ -1,13 +0,0 @@
import { Html, Head, Main, NextScript } from "next/document";
export default function Document() {
return (
<Html lang="en">
<Head />
<body className="antialiased">
<Main />
<NextScript />
</body>
</Html>
);
}

0
src/pages/api/fakeApi.ts Normal file
View File

View File

@ -1,13 +0,0 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next";
type Data = {
name: string;
};
export default function handler(
req: NextApiRequest,
res: NextApiResponse<Data>,
) {
res.status(200).json({ name: "John Doe" });
}

Binary file not shown.

Binary file not shown.

View File

@ -1,115 +1,16 @@
import Image from "next/image";
import localFont from "next/font/local";
// pages/index.tsx or wherever you use the editor
const geistSans = localFont({
src: "./fonts/GeistVF.woff",
variable: "--font-geist-sans",
weight: "100 900",
});
const geistMono = localFont({
src: "./fonts/GeistMonoVF.woff",
variable: "--font-geist-mono",
weight: "100 900",
});
import dynamic from 'next/dynamic';
export default function Home() {
// Dynamically import the editor with SSR disabled
const MyEditor = dynamic(() => import('../components/ui/editor'), { ssr: false });
export default function HomePage() {
return (
<div
className={`${geistSans.variable} ${geistMono.variable} grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]`}
>
<main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol className="list-inside list-decimal text-sm text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
<li className="mb-2">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-semibold">
src/pages/index.tsx
</code>
.
</li>
<li>Save and see your changes instantly.</li>
</ol>
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=default-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:min-w-44"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=default-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
</div>
</main>
<footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=default-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=default-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=default-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org
</a>
</footer>
<div>
<h1>My Custom Editor</h1>
<MyEditor />
</div>
);
}
}

View File

@ -1,16 +1,18 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--background: #ffffff;
--foreground: #171717;
--primary-color: #2563eb;
--secondary-color: #9ca3af;
--border-color: #e5e7eb;
--dark-background: #0a0a0a;
--dark-foreground: #ededed;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
--background: var(--dark-background);
--foreground: var(--dark-foreground);
--border-color: #374151;
}
}
@ -19,3 +21,82 @@ body {
background: var(--background);
font-family: Arial, Helvetica, sans-serif;
}
.editor-container {
max-width: 800px;
margin: 2rem auto;
padding: 1rem;
border: 1px solid var(--border-color);
border-radius: 0.5rem;
background: var(--background);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.5rem;
border-bottom: 1px solid var(--border-color);
background: var(--background);
}
.button-styles {
padding: 0.5rem;
border: 1px solid transparent;
background-color: var(--primary-color);
color: white;
font-size: 0.875rem;
font-weight: 500;
border-radius: 0.25rem;
cursor: pointer;
transition: background-color 0.2s, border-color 0.2s;
}
.button-styles:hover {
background-color: #1e40af;
}
.button-styles:disabled {
background-color: var(--secondary-color);
cursor: not-allowed;
}
.tooltip-styles {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
background-color: var(--foreground);
color: var(--background);
border-radius: 0.25rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.placeholder-container {
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.resize-handle {
border-radius: 50%;
border: 2px solid white;
box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.2);
}
button {
font-size: 14px;
font-weight: bold;
color: #333;
}
.editor-container {
padding: 20px;
}
.editor-content {
min-height: 300px;
border: 1px solid #ddd;
padding: 10px;
margin-top: 20px;
}