add editor
parent
389e4f6e3a
commit
6b425f624f
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
|
@ -23,8 +23,13 @@
|
|||
"formidable": "^3.5.2",
|
||||
"lucide-react": "^0.461.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"nanoid": "^5.0.9",
|
||||
"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-dom": "^18.3.1",
|
||||
"react-draggable": "^4.4.6",
|
||||
|
@ -47,5 +52,6 @@
|
|||
"tailwindcss": "^3.4.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"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 |
Binary file not shown.
After Width: | Height: | Size: 182 KiB |
|
@ -1,16 +1,20 @@
|
|||
// extension.ts
|
||||
import { defineBasicExtension } from 'prosekit/basic';
|
||||
import { union } from 'prosekit/core';
|
||||
import { defineImage } from './image'; // Assuming this already exists
|
||||
import { union , type Extension} from 'prosekit/core';
|
||||
import { defineImage } from './imgextension/image'; // Assuming this already exists
|
||||
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() {
|
||||
|
||||
|
||||
return union([
|
||||
defineBasicExtension(), // Adds basic functionality (e.g., text, paragraphs)
|
||||
defineImage(), // Adds image handling
|
||||
defineImage(), // Adds image handling :insert image
|
||||
definesignature(), // Adds the signature node
|
||||
definePlaceholder(), // Adds the placeholder node
|
||||
definePlaceholder(), // placeholder extension
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Extension, defineCommands,insertNode } from "prosekit/core";
|
||||
import { ImageAttrs } from "./image-spec";
|
||||
import { ImageAttrs } from "../imgextension/image-spec";
|
||||
|
||||
export type ImageCommandsExtension = Extension <{
|
||||
Commands: {
|
|
@ -24,8 +24,8 @@ export function defineImageSpec(): ImageSpecExtension {
|
|||
attrs: {
|
||||
src: { default: null },
|
||||
alt: { default: '' },
|
||||
width: { default: null },
|
||||
height: { default: null },
|
||||
width: { default: 200 },
|
||||
height: { default: 200 },
|
||||
},
|
||||
group: 'block',
|
||||
defining: true,
|
||||
|
@ -69,7 +69,7 @@ export function defineImageSpec(): ImageSpecExtension {
|
|||
if (width) attrs.width = width.toString()
|
||||
if(height) attrs.height = height.toString()
|
||||
|
||||
return ['img', attrs] ;
|
||||
return ['img', { src, alt, width: `${width}`, height: `${height}` }]
|
||||
},
|
||||
});
|
||||
}
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
|
@ -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')
|
||||
];
|
||||
},
|
||||
});
|
||||
}
|
|
@ -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()]);
|
||||
}
|
|
@ -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
|
||||
];
|
||||
},
|
||||
});
|
||||
}
|
|
@ -1,4 +1,3 @@
|
|||
import { DraggableSignature } from "@/components/ui/dragSize";
|
||||
import { defineNodeSpec } from "prosekit/core";
|
||||
|
||||
export function definesignature(){
|
||||
|
|
|
@ -1,84 +1,22 @@
|
|||
import React, { useEffect, useState, FC, ReactNode } from 'react';
|
||||
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<{
|
||||
interface ImageUploadPopoverProps {
|
||||
tooltip: string;
|
||||
disabled: boolean;
|
||||
children: ReactNode;
|
||||
}> = ({ tooltip, disabled, children }) => {
|
||||
}
|
||||
|
||||
export const ImageUploadPopover: FC<ImageUploadPopoverProps> = ({ tooltip, disabled, children }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [webUrl, setWebUrl] = useState('');
|
||||
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 handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
|
@ -87,29 +25,14 @@ export const ImageUploadPopover: FC<{
|
|||
|
||||
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);
|
||||
|
||||
// Simulated upload
|
||||
const url = `/uploads/${file.name}`;
|
||||
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;
|
||||
|
||||
|
@ -117,51 +40,47 @@ export const ImageUploadPopover: FC<{
|
|||
if (imageUrl) {
|
||||
editor.commands.insertImage({
|
||||
src: imageUrl,
|
||||
alt: imageService.getImageMetadata(imageUrl)?.originalName || 'image',
|
||||
alt: 'Uploaded Image',
|
||||
width: imageWidth,
|
||||
height: imageHeight,
|
||||
});
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
setWebUrl('');
|
||||
setUploadedUrl('');
|
||||
};
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (!open) {
|
||||
setTimeout(() => {
|
||||
setWebUrl('');
|
||||
setUploadedUrl('');
|
||||
}, 300);
|
||||
}
|
||||
setOpen(open);
|
||||
setImageWidth(150);
|
||||
setImageHeight(150);
|
||||
};
|
||||
|
||||
return (
|
||||
<PopoverRoot open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverRoot open={open} onOpenChange={setOpen}>
|
||||
<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">
|
||||
<PopoverContent className="flex flex-col gap-y-4 p-6 text-sm w-sm">
|
||||
{!uploadedUrl && (
|
||||
<>
|
||||
<label>Embed Link</label>
|
||||
<label htmlFor="embed-link-input">Embed Link</label>
|
||||
<input
|
||||
id="embed-link-input"
|
||||
className="input-style"
|
||||
placeholder="Paste the image link..."
|
||||
type="url"
|
||||
value={webUrl}
|
||||
onChange={handleWebUrlChange}
|
||||
onChange={(e) => setWebUrl(e.target.value)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!webUrl && (
|
||||
<>
|
||||
<label>Upload</label>
|
||||
<label htmlFor="file-upload-input">Upload</label>
|
||||
<input
|
||||
id="file-upload-input"
|
||||
className="input-style"
|
||||
accept="image/*"
|
||||
type="file"
|
||||
|
@ -171,23 +90,32 @@ export const ImageUploadPopover: FC<{
|
|||
</>
|
||||
)}
|
||||
|
||||
{(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'}
|
||||
{(uploadedUrl || webUrl) && (
|
||||
<>
|
||||
<img src={uploadedUrl || webUrl} alt="Preview" style={{ width: imageWidth, height: imageHeight }} />
|
||||
<label htmlFor="width-slider">Width: {imageWidth}px</label>
|
||||
<input
|
||||
id="width-slider"
|
||||
type="range"
|
||||
min="50"
|
||||
max="1000"
|
||||
value={imageWidth}
|
||||
onChange={(e) => setImageWidth(Number(e.target.value))}
|
||||
/>
|
||||
<label htmlFor="height-slider">Height: {imageHeight}px</label>
|
||||
<input
|
||||
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>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</PopoverRoot>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -2,23 +2,24 @@ 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 });
|
||||
}, []);
|
||||
|
||||
// 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
|
||||
useEffect(() => {
|
||||
const savedContent = localStorage.getItem('editorContent');
|
||||
|
@ -34,122 +35,82 @@ export default function Editor() {
|
|||
alert('Content saved');
|
||||
};
|
||||
|
||||
// Handle placeholder resizing and image resizing together
|
||||
const handleResize = (e: React.MouseEvent) => {
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
// Handle signature selection
|
||||
const handleSignatureSelect = (src: string, name: string) => {
|
||||
if (currentSignatureParty) {
|
||||
const tr = editor.state.tr;
|
||||
|
||||
const onMouseMove = (event: MouseEvent) => {
|
||||
const dx = event.clientX - startX;
|
||||
const dy = event.clientY - startY;
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (node.type.name === 'placeholder' && node.attrs.party === currentSignatureParty) {
|
||||
const nodesToInsert = [];
|
||||
|
||||
setPlaceholderSize((prevSize) => ({
|
||||
width: Math.max(100, prevSize.width + dx),
|
||||
height: Math.max(50, prevSize.height + dy),
|
||||
}));
|
||||
};
|
||||
// Add the custom name heading
|
||||
if (name) {
|
||||
const customHeading = editor.schema.nodes.heading.create(
|
||||
{ level: 1 },
|
||||
editor.schema.text(name)
|
||||
);
|
||||
nodesToInsert.push(customHeading);
|
||||
}
|
||||
|
||||
const onMouseUp = () => {
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
// Add the selected signature image
|
||||
const imageNode = editor.schema.nodes.image.create({
|
||||
src,
|
||||
alt: `${name || currentSignatureParty} Party's Signature`,
|
||||
});
|
||||
nodesToInsert.push(imageNode);
|
||||
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('mouseup', onMouseUp);
|
||||
// Replace the placeholder with the new nodes
|
||||
tr.replaceWith(pos, pos + node.nodeSize, nodesToInsert);
|
||||
}
|
||||
});
|
||||
|
||||
editor.view.dispatch(tr);
|
||||
|
||||
// Close the modal and reset the party state
|
||||
setSignatureModalOpen(false);
|
||||
setCurrentSignatureParty(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 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 />
|
||||
<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} />}
|
||||
{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>
|
||||
</ProseKit>
|
||||
);
|
||||
|
|
|
@ -1,37 +1,111 @@
|
|||
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);
|
||||
export const SignatureModal: React.FC<{
|
||||
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 = [
|
||||
'signatures/111.jpg',
|
||||
'signatures/4153.jpg',
|
||||
'signatures/111.jpg',
|
||||
];
|
||||
|
||||
const handleSelect = (src: string) => {
|
||||
onSelect(src);
|
||||
setIsOpen(false);
|
||||
const handleSelectSignature = (src: string) => {
|
||||
setSelectedSignature(src);
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (selectedSignature) {
|
||||
const defaultName = party === 'first' ? 'First Party' : 'Second Party';
|
||||
onSelect(
|
||||
selectedSignature,
|
||||
customName || defaultName
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
</>
|
||||
<Modal
|
||||
isOpen={true}
|
||||
onRequestClose={onClose}
|
||||
contentLabel={`Select ${party ? party.toUpperCase() : ''} Signature`}
|
||||
style={{
|
||||
content: {
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
right: 'auto',
|
||||
bottom: 'auto',
|
||||
marginRight: '-50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
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>
|
||||
);
|
||||
};
|
|
@ -3,10 +3,58 @@ import { useEditor } from 'prosekit/react'
|
|||
import Button from './button'
|
||||
import type { EditorExtension } from '../extension/extension'
|
||||
import { ImageUploadPopover} from '../image-Upload'
|
||||
import { useState } from 'react'
|
||||
|
||||
export default function Toolbar() {
|
||||
|
||||
export default function Toolbar(){
|
||||
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 (
|
||||
<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
|
||||
|
@ -78,6 +126,12 @@ export default function Toolbar() {
|
|||
>
|
||||
<div className='i-lucide-image h-5 w-5 ' />
|
||||
</ImageUploadPopover>
|
||||
|
||||
<Button onClick={handleAddPlaceholders} tooltip=" signature">
|
||||
Add
|
||||
</Button>
|
||||
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
}
|
|
@ -1,6 +1,12 @@
|
|||
import "@/styles/globals.css";
|
||||
import type { AppProps } from "next/app";
|
||||
import { useEffect } from "react";
|
||||
import Modal from "react-modal";
|
||||
|
||||
export default function App({ Component, pageProps }: AppProps) {
|
||||
useEffect(() => {
|
||||
Modal.setAppElement("#__next"); // This sets the app element for react-modal
|
||||
}, []);
|
||||
|
||||
return <Component {...pageProps} />;
|
||||
}
|
|
@ -11,6 +11,7 @@ export default function HomePage() {
|
|||
<div>
|
||||
<h1>My Custom Editor</h1>
|
||||
<MyEditor />
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -76,6 +76,7 @@ body {
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
background-color: #333;
|
||||
}
|
||||
|
||||
.resize-handle {
|
||||
|
@ -100,3 +101,71 @@ button {
|
|||
padding: 10px;
|
||||
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);
|
||||
}
|
Loading…
Reference in New Issue