feat: add imageSignature
parent
f9c105c95d
commit
389e4f6e3a
File diff suppressed because it is too large
Load Diff
37
package.json
37
package.json
|
@ -9,18 +9,43 @@
|
||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "19.0.0-rc-66855b96-20241106",
|
"@aws-sdk/client-s3": "^3.691.0",
|
||||||
"react-dom": "19.0.0-rc-66855b96-20241106",
|
"@aws-sdk/s3-request-presigner": "^3.691.0",
|
||||||
"next": "15.0.3"
|
"@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": {
|
"devDependencies": {
|
||||||
"typescript": "^5",
|
"@types/aws-sdk": "^0.0.42",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/multer": "^1.4.12",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
|
"@types/react-modal": "^3.16.3",
|
||||||
|
"eslint": "^8",
|
||||||
|
"eslint-config-next": "15.0.3",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"eslint": "^8",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"eslint-config-next": "15.0.3"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Binary file not shown.
After Width: | Height: | Size: 51 KiB |
Binary file not shown.
After Width: | Height: | Size: 8.9 KiB |
Binary file not shown.
After Width: | Height: | Size: 898 KiB |
|
@ -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>;
|
|
@ -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})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
@ -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] ;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
|
@ -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()
|
||||||
|
])
|
||||||
|
}
|
|
@ -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
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
|
@ -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;`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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.
|
@ -1,115 +1,16 @@
|
||||||
import Image from "next/image";
|
// pages/index.tsx or wherever you use the editor
|
||||||
import localFont from "next/font/local";
|
|
||||||
|
|
||||||
const geistSans = localFont({
|
import dynamic from 'next/dynamic';
|
||||||
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",
|
|
||||||
});
|
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div
|
<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)]`}
|
<h1>My Custom Editor</h1>
|
||||||
>
|
<MyEditor />
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
|
@ -1,16 +1,18 @@
|
||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
--background: #ffffff;
|
||||||
--foreground: #171717;
|
--foreground: #171717;
|
||||||
|
--primary-color: #2563eb;
|
||||||
|
--secondary-color: #9ca3af;
|
||||||
|
--border-color: #e5e7eb;
|
||||||
|
--dark-background: #0a0a0a;
|
||||||
|
--dark-foreground: #ededed;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root {
|
||||||
--background: #0a0a0a;
|
--background: var(--dark-background);
|
||||||
--foreground: #ededed;
|
--foreground: var(--dark-foreground);
|
||||||
|
--border-color: #374151;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -19,3 +21,82 @@ body {
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
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;
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue