add editor

master
shahid 2024-12-04 22:56:43 +05:30
parent 389e4f6e3a
commit 6b425f624f
23 changed files with 745 additions and 1870 deletions

1618
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -23,8 +23,13 @@
"formidable": "^3.5.2", "formidable": "^3.5.2",
"lucide-react": "^0.461.0", "lucide-react": "^0.461.0",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"nanoid": "^5.0.9",
"next": "15.0.3", "next": "15.0.3",
"prosekit": "^0.10.2", "prosekit": "^0.10.4",
"prosemirror-commands": "^1.6.2",
"prosemirror-model": "^1.24.0",
"prosemirror-state": "^1.4.3",
"prosemirror-view": "^1.37.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-draggable": "^4.4.6", "react-draggable": "^4.4.6",
@ -47,5 +52,6 @@
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"typescript": "^5" "typescript": "^5"
} },
"packageManager": "pnpm@9.9.0+sha512.60c18acd138bff695d339be6ad13f7e936eea6745660d4cc4a776d5247c540d0edee1a563695c183a66eb917ef88f2b4feb1fc25f32a7adcadc7aaf3438e99c1"
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

BIN
public/uploads/image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

View File

@ -1,16 +1,20 @@
// extension.ts // extension.ts
import { defineBasicExtension } from 'prosekit/basic'; import { defineBasicExtension } from 'prosekit/basic';
import { union } from 'prosekit/core'; import { union , type Extension} from 'prosekit/core';
import { defineImage } from './image'; // Assuming this already exists import { defineImage } from './imgextension/image'; // Assuming this already exists
import { definesignature } from './signature/signature'; // Import the signature node import { definesignature } from './signature/signature'; // Import the signature node
import { definePlaceholder } from './signature/signature-spec'; // Import the placeholder node import { definePlaceholder } from './signature/placeholder'; // Import the placeholder node
import { placeholderInteractionPlugin } from './signature/placeholder-plugin';
export function defineExtension() { export function defineExtension() {
return union([ return union([
defineBasicExtension(), // Adds basic functionality (e.g., text, paragraphs) defineBasicExtension(), // Adds basic functionality (e.g., text, paragraphs)
defineImage(), // Adds image handling defineImage(), // Adds image handling :insert image
definesignature(), // Adds the signature node definesignature(), // Adds the signature node
definePlaceholder(), // Adds the placeholder node definePlaceholder(), // placeholder extension
]); ]);
} }

View File

@ -1,5 +1,5 @@
import { Extension, defineCommands,insertNode } from "prosekit/core"; import { Extension, defineCommands,insertNode } from "prosekit/core";
import { ImageAttrs } from "./image-spec"; import { ImageAttrs } from "../imgextension/image-spec";
export type ImageCommandsExtension = Extension <{ export type ImageCommandsExtension = Extension <{
Commands: { Commands: {

View File

@ -24,8 +24,8 @@ export function defineImageSpec(): ImageSpecExtension {
attrs: { attrs: {
src: { default: null }, src: { default: null },
alt: { default: '' }, alt: { default: '' },
width: { default: null }, width: { default: 200 },
height: { default: null }, height: { default: 200 },
}, },
group: 'block', group: 'block',
defining: true, defining: true,
@ -69,7 +69,7 @@ export function defineImageSpec(): ImageSpecExtension {
if (width) attrs.width = width.toString() if (width) attrs.width = width.toString()
if(height) attrs.height = height.toString() if(height) attrs.height = height.toString()
return ['img', attrs] ; return ['img', { src, alt, width: `${width}`, height: `${height}` }]
}, },
}); });
} }

View File

@ -0,0 +1,51 @@
import { defineCommands, insertNode, type Extension } from "prosekit/core";
import { PlaceholderAttrs } from "./placeholder-spec";
export type PlaceholderCommandsExtension = Extension<{
Commands: {
insertPlaceholder: [attrs?: PlaceholderAttrs];
insertSignatureInPlaceholder: [src: string, attrs?: Partial<PlaceholderAttrs>]
};
}>;
export function definePlaceholderCommands(): PlaceholderCommandsExtension {
return defineCommands({
insertPlaceholder: (attrs?: PlaceholderAttrs) => {
return insertNode({
type: 'placeholder',
attrs: {
id: attrs?.id || `placeholder-${Math.random().toString(36).substr(2, 9)}`,
...attrs
}
});
},
insertSignatureInPlaceholder: (src: string, attrs?: Partial<PlaceholderAttrs>) => {
return (state, dispatch) => {
let placeholderPos = -1;
// Find the first placeholder with matching party that doesn't have a signature
state.doc.descendants((node, pos) => {
if (node.type.name === 'placeholder' &&
node.attrs.party === attrs?.party &&
!node.attrs.signatureSrc) {
placeholderPos = pos;
return false;
}
});
if (placeholderPos !== -1) {
const tr = state.tr.setNodeMarkup(placeholderPos, undefined, {
...state.doc.nodeAt(placeholderPos)?.attrs,
signatureSrc: src,
...attrs
});
if (dispatch) dispatch(tr);
return true;
}
return false;
};
}
});
}

View File

@ -0,0 +1,70 @@
// placeholder-plugin.ts
import { Plugin, PluginKey } from 'prosemirror-state';
export const placeholderInteractionPlugin = new Plugin({
key: new PluginKey('placeholderInteraction'),
state: {
init() {
return {
dragging: null,
resizing: null,
activePlaceholderId: null
};
},
apply(tr, value) {
// Update active placeholder ID if set in transaction metadata
const placeholderId = tr.getMeta('activePlaceholder');
return placeholderId !== undefined
? { ...value, activePlaceholderId: placeholderId }
: value;
}
},
props: {
handleDOMEvents: {
mousedown(view, event) {
const target = event.target as HTMLElement;
// Check for resize handle
if (target.classList.contains('resize-handle')) {
const placeholder = target.closest('.signature-placeholder');
if (!placeholder) return false;
const startX = event.clientX;
const startY = event.clientY;
const startWidth = placeholder.clientWidth;
const startHeight = placeholder.clientHeight;
const mouseMoveHandler = (moveEvent: MouseEvent) => {
const deltaX = moveEvent.clientX - startX;
const deltaY = moveEvent.clientY - startY;
const newWidth = Math.max(50, startWidth + deltaX);
const newHeight = Math.max(50, startHeight + deltaY);
// Find the node position
const pos = view.posAtDOM(placeholder, 0);
view.dispatch(
view.state.tr.setMeta('resize', {
width: newWidth,
height: newHeight,
pos
})
);
};
const mouseUpHandler = () => {
document.removeEventListener('mousemove', mouseMoveHandler);
document.removeEventListener('mouseup', mouseUpHandler);
};
document.addEventListener('mousemove', mouseMoveHandler);
document.addEventListener('mouseup', mouseUpHandler);
return true;
}
return false;
}
}
}
});

View File

@ -0,0 +1,93 @@
import { defineNodeSpec, type Extension } from 'prosekit/core';
export interface PlaceholderAttrs {
id?: string | null;
width?: number | null;
height?: number | null;
signatureSrc?: string | null;
resizable?: boolean;
party?: 'first' | 'second' | null;
}
export type PlaceholderSpecExtension = Extension<{
Nodes: {
placeholder: PlaceholderAttrs
}
}>;
export function definePlaceholderSpec(): PlaceholderSpecExtension {
return defineNodeSpec({
name: 'placeholder',
group: 'block',
content: 'inline*',
attrs: {
id: { default: null },
width: { default: 200 },
height: { default: 100 },
signatureSrc: { default: null },
resizable: { default: true },
party: { default: null }
},
parseDOM: [
{
tag: 'div[data-placeholder]',
getAttrs: (element): PlaceholderAttrs => {
if (!(element instanceof HTMLElement)) return { id: null };
return {
id: element.getAttribute('data-id'),
width: parseInt(element.getAttribute('data-width') || '200', 10),
height: parseInt(element.getAttribute('data-height') || '100', 10),
signatureSrc: element.getAttribute('data-signature-src'),
resizable: element.getAttribute('data-resizable') !== 'false',
party: element.getAttribute('data-party') as 'first' | 'second' | null
};
},
},
],
toDOM(node) {
const { width, height, signatureSrc, resizable, party } = node.attrs as PlaceholderAttrs;
const borderStyle = signatureSrc
? 'border: 2px solid ;'
: (party === 'first'
? 'border: 2px dashed grey;'
: 'border: 2px dashed grey;');
const resizeStyle = resizable ? 'resize: both; overflow: auto;' : '';
return [
'div',
{
'data-placeholder': '',
'data-id': node.attrs.id,
'data-width': width,
'data-height': height,
'data-signature-src': signatureSrc,
'data-resizable': resizable,
'data-party': party,
style: `
width: ${width}px;
height: ${height}px;
${borderStyle}
${resizeStyle}
position: relative;
display: flex;
justify-content: center;
align-items: center;
cursor: move;
`,
draggable: 'true'
},
signatureSrc ? [
'img',
{
src: signatureSrc,
style: 'max-width: 100%; max-height: 100%; object-fit: contain;'
}
] : (party === 'first' ? 'First Party Signature' : 'Second Party Signature')
];
},
});
}

View File

@ -0,0 +1,9 @@
import { union, type Union } from "prosekit/core";
import { definePlaceholderSpec, type PlaceholderSpecExtension } from "./placeholder-spec";
import { definePlaceholderCommands, type PlaceholderCommandsExtension } from "./placeholder-command";
export type PlaceholderExtension = Union<[PlaceholderSpecExtension, PlaceholderCommandsExtension]>;
export function definePlaceholder(): PlaceholderExtension {
return union([definePlaceholderSpec(), definePlaceholderCommands()]);
}

View File

@ -1,35 +0,0 @@
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

@ -1,4 +1,3 @@
import { DraggableSignature } from "@/components/ui/dragSize";
import { defineNodeSpec } from "prosekit/core"; import { defineNodeSpec } from "prosekit/core";
export function definesignature(){ export function definesignature(){

View File

@ -1,84 +1,22 @@
import React, { useEffect, useState, FC, ReactNode } from 'react';
import { useEditor } from 'prosekit/react'; import { useEditor } from 'prosekit/react';
import { PopoverContent, PopoverRoot, PopoverTrigger } from 'prosekit/react/popover'; import { PopoverContent, PopoverRoot, PopoverTrigger } from 'prosekit/react/popover';
import { useEffect, useState, FC, ReactNode } from 'react';
import Button from './ui/button'; import Button from './ui/button';
import type { EditorExtension } from '../components/extension/extension'; import type { EditorExtension } from '../components/extension/extension';
// Image handling service with direct URL storage interface ImageUploadPopoverProps {
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; tooltip: string;
disabled: boolean; disabled: boolean;
children: ReactNode; children: ReactNode;
}> = ({ tooltip, disabled, children }) => { }
export const ImageUploadPopover: FC<ImageUploadPopoverProps> = ({ tooltip, disabled, children }) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [webUrl, setWebUrl] = useState(''); const [webUrl, setWebUrl] = useState('');
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [uploadedUrl, setUploadedUrl] = useState<string>(''); const [uploadedUrl, setUploadedUrl] = useState('');
const [imageWidth, setImageWidth] = useState(150);
const [imageHeight, setImageHeight] = useState(150);
const editor = useEditor<EditorExtension>(); const editor = useEditor<EditorExtension>();
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
@ -87,29 +25,14 @@ export const ImageUploadPopover: FC<{
setIsUploading(true); setIsUploading(true);
try { try {
const url = await imageService.uploadImage(file); // Simulated upload
const url = `/uploads/${file.name}`;
// Store metadata
const metadata: ImageMetadata = {
url,
originalName: file.name,
uploadedAt: Date.now(),
size: file.size
};
imageService.storeImageMetadata(url, metadata);
setUploadedUrl(url); setUploadedUrl(url);
} catch (error) {
console.error('Upload failed:', error);
} finally { } finally {
setIsUploading(false); setIsUploading(false);
} }
}; };
const handleWebUrlChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setWebUrl(event.target.value);
};
const handleSubmit = () => { const handleSubmit = () => {
if (!editor) return; if (!editor) return;
@ -117,51 +40,47 @@ export const ImageUploadPopover: FC<{
if (imageUrl) { if (imageUrl) {
editor.commands.insertImage({ editor.commands.insertImage({
src: imageUrl, src: imageUrl,
alt: imageService.getImageMetadata(imageUrl)?.originalName || 'image', alt: 'Uploaded Image',
width: imageWidth,
height: imageHeight,
}); });
} }
setOpen(false); setOpen(false);
setWebUrl(''); setWebUrl('');
setUploadedUrl(''); setUploadedUrl('');
}; setImageWidth(150);
setImageHeight(150);
const handleOpenChange = (open: boolean) => {
if (!open) {
setTimeout(() => {
setWebUrl('');
setUploadedUrl('');
}, 300);
}
setOpen(open);
}; };
return ( return (
<PopoverRoot open={open} onOpenChange={handleOpenChange}> <PopoverRoot open={open} onOpenChange={setOpen}>
<PopoverTrigger> <PopoverTrigger>
<Button pressed={open} disabled={disabled} tooltip={tooltip}> <Button pressed={open} disabled={disabled} tooltip={tooltip}>
{children} {children}
</Button> </Button>
</PopoverTrigger> </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"> <PopoverContent className="flex flex-col gap-y-4 p-6 text-sm w-sm">
{!uploadedUrl && ( {!uploadedUrl && (
<> <>
<label>Embed Link</label> <label htmlFor="embed-link-input">Embed Link</label>
<input <input
id="embed-link-input"
className="input-style" className="input-style"
placeholder="Paste the image link..." placeholder="Paste the image link..."
type="url" type="url"
value={webUrl} value={webUrl}
onChange={handleWebUrlChange} onChange={(e) => setWebUrl(e.target.value)}
/> />
</> </>
)} )}
{!webUrl && ( {!webUrl && (
<> <>
<label>Upload</label> <label htmlFor="file-upload-input">Upload</label>
<input <input
id="file-upload-input"
className="input-style" className="input-style"
accept="image/*" accept="image/*"
type="file" type="file"
@ -171,25 +90,34 @@ export const ImageUploadPopover: FC<{
</> </>
)} )}
{(webUrl || uploadedUrl) && ( {(uploadedUrl || webUrl) && (
<div className="flex flex-col gap-2"> <>
<div className="preview-container"> <img src={uploadedUrl || webUrl} alt="Preview" style={{ width: imageWidth, height: imageHeight }} />
<img <label htmlFor="width-slider">Width: {imageWidth}px</label>
src={uploadedUrl || webUrl} <input
alt="Preview" id="width-slider"
className="max-w-full h-auto" type="range"
/> min="50"
</div> max="1000"
<button value={imageWidth}
onClick={handleSubmit} onChange={(e) => setImageWidth(Number(e.target.value))}
disabled={(!uploadedUrl && !webUrl) || isUploading} />
className="button-style" <label htmlFor="height-slider">Height: {imageHeight}px</label>
> <input
{isUploading ? 'Uploading...' : 'Insert Image'} id="height-slider"
type="range"
min="50"
max="1000"
value={imageHeight}
onChange={(e) => setImageHeight(Number(e.target.value))}
/>
<button onClick={handleSubmit} disabled={!uploadedUrl && !webUrl} className="button-style">
Insert Image
</button> </button>
</div> </>
)} )}
</PopoverContent> </PopoverContent>
</PopoverRoot> </PopoverRoot>
); );
}; };

View File

@ -1,97 +0,0 @@
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

@ -2,23 +2,24 @@ import 'prosekit/basic/style.css';
import { createEditor } from 'prosekit/core'; import { createEditor } from 'prosekit/core';
import { ProseKit } from 'prosekit/react'; import { ProseKit } from 'prosekit/react';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import Draggable from 'react-draggable';
import Toolbar from '../ui/toolbars'; import Toolbar from '../ui/toolbars';
import Button from './button'; import Button from './button';
import { defineExtension } from '../extension/extension'; import { defineExtension } from '../extension/extension';
import { SignatureModal } from './signatureModal'; import { SignatureModal } from './signatureModal';
export default function Editor() { 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 // Initialize editor with extensions
const editor = useMemo(() => { const editor = useMemo(() => {
const extension = defineExtension(); const extension = defineExtension();
return createEditor({ extension }); return createEditor({ extension });
}, []); }, []);
// State for signature modal
const [isSignatureModalOpen, setSignatureModalOpen] = useState(false);
// Track the current party for signature insertion
const [currentSignatureParty, setCurrentSignatureParty] = useState<'first' | 'second' | null>(null);
// Load saved content on editor mount // Load saved content on editor mount
useEffect(() => { useEffect(() => {
const savedContent = localStorage.getItem('editorContent'); const savedContent = localStorage.getItem('editorContent');
@ -34,123 +35,83 @@ export default function Editor() {
alert('Content saved'); alert('Content saved');
}; };
// Handle placeholder resizing and image resizing together // Handle signature selection
const handleResize = (e: React.MouseEvent) => { const handleSignatureSelect = (src: string, name: string) => {
const startX = e.clientX; if (currentSignatureParty) {
const startY = e.clientY; const tr = editor.state.tr;
const onMouseMove = (event: MouseEvent) => { editor.state.doc.descendants((node, pos) => {
const dx = event.clientX - startX; if (node.type.name === 'placeholder' && node.attrs.party === currentSignatureParty) {
const dy = event.clientY - startY; const nodesToInsert = [];
setPlaceholderSize((prevSize) => ({ // Add the custom name heading
width: Math.max(100, prevSize.width + dx), if (name) {
height: Math.max(50, prevSize.height + dy), const customHeading = editor.schema.nodes.heading.create(
})); { level: 1 },
}; editor.schema.text(name)
);
const onMouseUp = () => { nodesToInsert.push(customHeading);
window.removeEventListener('mousemove', onMouseMove); }
window.removeEventListener('mouseup', onMouseUp);
}; // Add the selected signature image
const imageNode = editor.schema.nodes.image.create({
window.addEventListener('mousemove', onMouseMove); src,
window.addEventListener('mouseup', onMouseUp); alt: `${name || currentSignatureParty} Party's Signature`,
}; });
nodesToInsert.push(imageNode);
// Handle signature selection and render inside the placeholder
const handleSignatureSelect = (src: string) => { // Replace the placeholder with the new nodes
setSelectedSignature(src); tr.replaceWith(pos, pos + node.nodeSize, nodesToInsert);
setModalOpen(false); }
}; });
// Handle image removal editor.view.dispatch(tr);
const handleRemoveImage = () => {
setSelectedSignature(null); // Close the modal and reset the party state
setSignatureModalOpen(false);
setCurrentSignatureParty(null);
}
}; };
return ( return (
<ProseKit editor={editor}> <ProseKit editor={editor}>
<div className="editor-container"> <div className="editor-container">
<Toolbar /> <Toolbar />
<Button onClick={handleSave} tooltip="Save"> <Button onClick={handleSave} tooltip="Save">
Save Save
</Button> </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 */} {/* Signature Modal */}
{isModalOpen && <SignatureModal onSelect={handleSignatureSelect} />} {isSignatureModalOpen && (
<SignatureModal
party={currentSignatureParty}
onSelect={handleSignatureSelect}
onClose={() => {
setSignatureModalOpen(false);
setCurrentSignatureParty(null);
}}
/>
)}
<div ref={editor.mount} className="editor-content"></div> {/* Editor content mounting point */}
<div
ref={editor.mount}
className="editor-content"
onClick={(e) => {
// Check if clicked element is a placeholder
const placeholderEl = (e.target as HTMLElement).closest('[data-placeholder]');
if (placeholderEl) {
const party = placeholderEl.getAttribute('data-party');
if (party === 'first' || party === 'second') {
setCurrentSignatureParty(party as 'first' | 'second');
setSignatureModalOpen(true);
}
}
}}
></div>
</div> </div>
</ProseKit> </ProseKit>
); );
} }

View File

@ -1,37 +1,111 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import Modal from 'react-modal'; import Modal from 'react-modal';
export const SignatureModal: React.FC<{ onSelect: (src: string) => void }> = ({ onSelect }) => { export const SignatureModal: React.FC<{
const [isOpen, setIsOpen] = useState(false); party?: 'first' | 'second' | null,
onSelect: (src: string, name: string) => void,
onClose: () => void
}> = ({ party, onSelect, onClose }) => {
const [customName, setCustomName] = useState('');
const [selectedSignature, setSelectedSignature] = useState<string | null>(null);
const sampleSignatures = [ const sampleSignatures = [
'signatures/111.jpg', 'signatures/111.jpg',
'signatures/4153.jpg', 'signatures/4153.jpg',
'signatures/111.jpg',
]; ];
const handleSelect = (src: string) => { const handleSelectSignature = (src: string) => {
onSelect(src); setSelectedSignature(src);
setIsOpen(false); };
const handleConfirm = () => {
if (selectedSignature) {
const defaultName = party === 'first' ? 'First Party' : 'Second Party';
onSelect(
selectedSignature,
customName || defaultName
);
}
}; };
return ( return (
<> <Modal
<button onClick={() => setIsOpen(true)}>Add Signature</button> isOpen={true}
<Modal isOpen={isOpen} onRequestClose={() => setIsOpen(false)} contentLabel="Select Signature"> onRequestClose={onClose}
<h2>Select a Signature</h2> contentLabel={`Select ${party ? party.toUpperCase() : ''} Signature`}
<div style={{ display: 'flex', gap: '10px' }}> style={{
{sampleSignatures.map((src) => ( content: {
<img top: '50%',
key={src} left: '50%',
src={src} right: 'auto',
alt="Signature" bottom: 'auto',
style={{ cursor: 'pointer', width: '100px' }} marginRight: '-50%',
onClick={() => handleSelect(src)} transform: 'translate(-50%, -50%)',
/> display: 'flex',
))} flexDirection: 'column',
</div> alignItems: 'center',
</Modal> width: '400px',
</> }
}}
>
<h2>Select {party ? party.toUpperCase() : ''} Signature</h2>
<input
type="text"
placeholder={`Enter ${party} party name`}
value={customName}
onChange={(e) => setCustomName(e.target.value)}
style={{
marginBottom: '20px',
padding: '8px',
width: '100%',
border: '1px solid #ccc',
borderRadius: '4px',
}}
/>
<div style={{ display: 'flex', gap: '10px', marginBottom: '20px' }}>
{sampleSignatures.map((src) => (
<img
key={src}
src={src}
alt={`${party} Signature`}
style={{
cursor: 'pointer',
width: '100px',
border: selectedSignature === src ? '2px solid blue' : '2px solid transparent',
transition: 'border-color 0.3s',
}}
onClick={() => handleSelectSignature(src)}
/>
))}
</div>
<div style={{ display: 'flex', gap: '10px' }}>
<button
onClick={handleConfirm}
disabled={!selectedSignature}
style={{
padding: '8px 16px',
backgroundColor: selectedSignature ? '#4CAF50' : '#cccccc',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: selectedSignature ? 'pointer' : 'not-allowed'
}}
>
OK
</button>
<button
onClick={onClose}
style={{
padding: '8px 16px',
backgroundColor: '#f44336',
color: 'white',
border: 'none',
borderRadius: '4px',
}}
>
Cancel
</button>
</div>
</Modal>
); );
}; };

View File

@ -3,10 +3,58 @@ import { useEditor } from 'prosekit/react'
import Button from './button' import Button from './button'
import type { EditorExtension } from '../extension/extension' import type { EditorExtension } from '../extension/extension'
import { ImageUploadPopover} from '../image-Upload' import { ImageUploadPopover} from '../image-Upload'
import { useState } from 'react'
export default function Toolbar() {
export default function Toolbar(){
const editor = useEditor<EditorExtension>({ update: true }) const editor = useEditor<EditorExtension>({ update: true })
const handleAddPlaceholders = () => {
const tr = editor.state.tr;
// Find the current end position of the document to insert placeholders sequentially
const endPos = editor.state.doc.content.size;
if (!editor.state.doc.textContent.includes('First Party')) {
// Add the First Party placeholder
const firstHeading = editor.schema.nodes.heading.create(
{ level: 1 },
editor.schema.text('First Party')
);
const firstPlaceholder = editor.schema.nodes.placeholder.create({
id: 'first-party-placeholder',
party: 'first',
width: 250,
height: 100,
});
// Insert heading and placeholder
tr.insert(endPos, firstHeading);
tr.insert(endPos + firstHeading.nodeSize, firstPlaceholder);
} else if (!editor.state.doc.textContent.includes('Second Party')) {
// Add the Second Party placeholder
const secondHeading = editor.schema.nodes.heading.create(
{ level: 1 },
editor.schema.text('Second Party')
);
const secondPlaceholder = editor.schema.nodes.placeholder.create({
id: 'second-party-placeholder',
party: 'second',
width: 250,
height: 100,
});
// Insert heading and placeholder
tr.insert(endPos, secondHeading);
tr.insert(endPos + secondHeading.nodeSize, secondPlaceholder);
}
editor.view.dispatch(tr);
};
return ( 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'> <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 <Button
@ -78,6 +126,12 @@ export default function Toolbar() {
> >
<div className='i-lucide-image h-5 w-5 ' /> <div className='i-lucide-image h-5 w-5 ' />
</ImageUploadPopover> </ImageUploadPopover>
<Button onClick={handleAddPlaceholders} tooltip=" signature">
Add
</Button>
</div> </div>
) );
} }

View File

@ -1,6 +1,12 @@
import "@/styles/globals.css"; import "@/styles/globals.css";
import type { AppProps } from "next/app"; import type { AppProps } from "next/app";
import { useEffect } from "react";
import Modal from "react-modal";
export default function App({ Component, pageProps }: AppProps) { export default function App({ Component, pageProps }: AppProps) {
useEffect(() => {
Modal.setAppElement("#__next"); // This sets the app element for react-modal
}, []);
return <Component {...pageProps} />; return <Component {...pageProps} />;
} }

View File

@ -11,6 +11,7 @@ export default function HomePage() {
<div> <div>
<h1>My Custom Editor</h1> <h1>My Custom Editor</h1>
<MyEditor /> <MyEditor />
</div> </div>
); );
} }

View File

@ -76,6 +76,7 @@ body {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
position: relative; position: relative;
background-color: #333;
} }
.resize-handle { .resize-handle {
@ -100,3 +101,71 @@ button {
padding: 10px; padding: 10px;
margin-top: 20px; margin-top: 20px;
} }
input[type='range'] {
width: 100%;
margin: 8px 0;
background: var(--border-color);
height: 4px;
border-radius: 4px;
}
input[type='range']::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px;
height: 16px;
background: var(--primary-color);
border-radius: 50%;
cursor: pointer;
}
/* editor.css */
.editor-content div[data-placeholder] {
margin: 10px 0;
cursor: pointer;
}
[data-placeholder] {
transition: box-shadow 0.2s ease;
}
[data-placeholder]:active {
cursor: grabbing;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.editor-container {
padding: 20px;
border: 1px solid #ccc;
}
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
}
.modal {
background: white;
padding: 20px;
border-radius: 10px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.signature-options img {
border: 1px solid #ccc;
border-radius: 5px;
transition: transform 0.2s ease;
}
.signature-options img:hover {
transform: scale(1.1);
}