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"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
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>
|
||||
)
|
||||
}
|
|
@ -3,4 +3,4 @@ import type { AppProps } from "next/app";
|
|||
|
||||
export default function App({ Component, pageProps }: AppProps) {
|
||||
return <Component {...pageProps} />;
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue